@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,697 @@
1
+ """What a service spends on models (DEC-0021, DEC-0065, DEC-0091): usage receipts, the bounded ledger, the usage
2
+ report and its rules (FAC-SEM-025), OpenRouter attribution (FAC-SEM-098) and every spending limit (DEC-0027).
3
+ Part of the service kit (FAA-65, layer L3)."""
4
+ from __future__ import annotations
5
+
6
+ import datetime as _dt
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
14
+
15
+ import sys as _sys # noqa: E402
16
+ from pathlib import Path as _Path # noqa: E402
17
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
18
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
19
+ from fabric_jsvalue import is_number as _number
20
+ from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms
21
+ import fabric_jsvalue as _jsv
22
+ import fabric_time as _ftime
23
+ from fabric_base import (
24
+ PROTOCOL,
25
+ ServiceError,
26
+ _O_BINARY,
27
+ now_iso,
28
+ )
29
+ from fabric_paths import (
30
+ _read_at,
31
+ atomic_write,
32
+ ensure_private_dir,
33
+ )
34
+
35
+
36
+ ROUTES = ("subscription", "key")
37
+
38
+
39
+ #: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
40
+ USAGE_ROUTES = ROUTES + ("platform-metered",)
41
+
42
+
43
+ USAGE_PATH = "/fabric/v1/usage"
44
+
45
+
46
+ USAGE_DAYS = 31
47
+
48
+
49
+ COST_BASES = ("provider", "price-list", "unknown")
50
+
51
+
52
+ _PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
53
+
54
+
55
+ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
56
+ cache_read_tokens: int = 0, cache_write_tokens: int = 0,
57
+ cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
58
+ at: Optional[str] = None, route: Optional[str] = None,
59
+ project_id: Optional[str] = None, task_run_id: Optional[str] = None,
60
+ tokens_measured: bool = True) -> Dict[str, Any]:
61
+ """One model call, from the provider's own usage numbers. No prompt, output or caller.
62
+
63
+ `tokens_measured=False` marks a call whose token counts are a lower bound or unknown (the provider returned none,
64
+ the stream was cut): the report counts it in `unmeasuredCalls` and its token totals are a lower bound (DEC-0091).
65
+
66
+ `project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
67
+ call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
68
+
69
+ `cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
70
+ never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
71
+ (computed from a published price list); it is `unknown` when there is no cost."""
72
+ if not _PROVIDER.match(provider or ""):
73
+ raise ServiceError("provider must be a lowercase name such as anthropic or openrouter.")
74
+ model = str(model or "").strip()
75
+ if not model or len(model) > 128:
76
+ raise ServiceError("model must be 1 to 128 characters.")
77
+ counts = {"inputTokens": input_tokens, "outputTokens": output_tokens,
78
+ "cacheReadTokens": cache_read_tokens, "cacheWriteTokens": cache_write_tokens}
79
+ for name, value in counts.items():
80
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
81
+ raise ServiceError("%s must be a non-negative integer." % name)
82
+ if cost_usd is not None and (isinstance(cost_usd, bool) or not isinstance(cost_usd, (int, float)) or cost_usd < 0 or cost_usd != cost_usd):
83
+ raise ServiceError("cost_usd must be a non-negative number or None.")
84
+ basis = cost_basis or ("unknown" if cost_usd is None else "provider")
85
+ if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
86
+ raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
87
+ if route is not None and route not in USAGE_ROUTES:
88
+ raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
89
+ receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
90
+ "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
91
+ if tokens_measured is not True:
92
+ if tokens_measured is not False:
93
+ raise ServiceError("tokens_measured is True or False.")
94
+ receipt["tokensMeasured"] = False
95
+ if route is not None:
96
+ receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
97
+ if task_run_id is not None and project_id is None:
98
+ raise ServiceError("a task run belongs to a project: give project_id with task_run_id.")
99
+ if project_id is not None:
100
+ import fabric_identifiers # noqa: E402 - beside this file
101
+ if not (isinstance(project_id, str) and _URI.fullmatch(project_id)):
102
+ raise ServiceError("project_id is the URI of the project the service ran the call for.")
103
+ receipt["projectId"] = fabric_identifiers.normal_identifier(project_id)
104
+ if task_run_id is not None:
105
+ if not (isinstance(task_run_id, str) and _TASK_RUN_ID.fullmatch(task_run_id)):
106
+ raise ServiceError("task_run_id is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.")
107
+ receipt["taskRunId"] = task_run_id
108
+ return receipt
109
+
110
+
111
+ _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S+", re.ASCII)
112
+
113
+
114
+ _TASK_RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", re.ASCII)
115
+
116
+
117
+ def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[Dict[str, Any]]:
118
+ """DEC-0065's `byProject`: the window's calls by the project the service ran them for, each project once (by its
119
+ normal form, DEC-0076) and each task run once within it. A call for no project is in no row."""
120
+ projects: Dict[str, Dict[str, Any]] = {}
121
+ for r in receipts:
122
+ if not (first <= str(r.get("at", ""))[:10] <= last) or not r.get("projectId"):
123
+ continue
124
+ row = projects.setdefault(r["projectId"], {"projectId": r["projectId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0, "_runs": {}})
125
+ row["calls"] += 1
126
+ if r.get("costUsd") is None:
127
+ row["unpricedCalls"] += 1
128
+ else:
129
+ row["_cost"] += float(r["costUsd"])
130
+ if r.get("taskRunId"):
131
+ run = row["_runs"].setdefault(r["taskRunId"], {"taskRunId": r["taskRunId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0})
132
+ run["calls"] += 1
133
+ if r.get("costUsd") is None:
134
+ run["unpricedCalls"] += 1
135
+ else:
136
+ run["_cost"] += float(r["costUsd"])
137
+ out = []
138
+ for key in sorted(projects):
139
+ row = projects[key]
140
+ runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
141
+ row = _close_totals(row)
142
+ if runs:
143
+ row["taskRuns"] = runs
144
+ out.append(row)
145
+ return out
146
+
147
+
148
+ def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
149
+ row["calls"] += 1
150
+ if receipt.get("tokensMeasured") is False:
151
+ row["unmeasuredCalls"] = row.get("unmeasuredCalls", 0) + 1 # written only when above 0: absent is 0 (DEC-0091)
152
+ for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
153
+ row[name] += int(receipt.get(name) or 0)
154
+ if receipt.get("costUsd") is None:
155
+ row["unpricedCalls"] += 1
156
+ else:
157
+ row["_cost"] += float(receipt["costUsd"])
158
+
159
+
160
+ def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
161
+ cost = row.pop("_cost")
162
+ row["costUsd"] = None if row["calls"] and row["unpricedCalls"] == row["calls"] else round(cost, 6)
163
+ return row
164
+
165
+
166
+ _USAGE_WINDOW_DAYS = 31
167
+
168
+
169
+ def _usage_count(value: Any) -> int:
170
+ """The contract's `count`: Number.isSafeInteger and not negative, so 5.0 (which JavaScript reads as 5) counts."""
171
+ return int(value) if not isinstance(value, bool) and _jsv.safe_integer(value) and value >= 0 else 0
172
+
173
+
174
+ def _usage_money(value: Any) -> Optional[float]:
175
+ """A JSON number as JavaScript holds it (an integer beyond a double is Infinity), or None for anything else."""
176
+ return _jsv.double(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
177
+
178
+
179
+ def _usage_cost(value: Any) -> float:
180
+ """The contract's `cost`: a known, finite, non-negative cost, or 0."""
181
+ money = _usage_money(value)
182
+ return money if money is not None and math.isfinite(money) and money >= 0 else 0.0
183
+
184
+
185
+ def _exceeds(a: float, b: float) -> bool:
186
+ return a - b > 0.000001 + 1e-9 * max(abs(a), abs(b))
187
+
188
+
189
+ def _usd(value: float) -> str:
190
+ rounded = round(value * 1e6) / 1e6
191
+ return str(int(rounded)) if rounded == int(rounded) else repr(rounded)
192
+
193
+
194
+ def _usage_list(value: Any) -> List[Dict[str, Any]]:
195
+ return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else []
196
+
197
+
198
+ def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
199
+ calls, unpriced = _usage_count(row.get("calls")), _usage_count(row.get("unpricedCalls"))
200
+ flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
201
+ if unpriced > calls:
202
+ return flag(at + "/unpricedCalls", "more unpriced calls than calls")
203
+ if _usage_count(row.get("unmeasuredCalls")) > calls: # DEC-0091: unknown token counts are some of the calls
204
+ return flag(at + "/unmeasuredCalls", "more calls with unknown tokens than calls")
205
+ if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
206
+ return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
207
+ if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
208
+ return flag(at + "/costUsd", "priced calls carry a cost; null is only for a row with no priced call")
209
+ return []
210
+
211
+
212
+ _MISSING_COST = object()
213
+
214
+
215
+ def usage_findings(report: Any) -> List[Dict[str, str]]:
216
+ """FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic of days and
217
+ models, the window (every date exists, not after `generatedAt`'s UTC date, at most 30 days before it), and the
218
+ project attribution (each project once by its normal form, each task run once; calls, priced calls and known costs
219
+ bounded). Malformed input answers findings or none, never an exception."""
220
+ import fabric_identifiers # noqa: E402
221
+ value = report if isinstance(report, dict) else {}
222
+ findings: List[Dict[str, str]] = []
223
+ flag = lambda path, message: findings.append({"code": "FAC-SEM-025", "instancePath": path, "message": message}) # noqa: E731
224
+ days = _usage_list(value.get("days"))
225
+ # The arithmetic.
226
+ previous = ""
227
+ for i, day in enumerate(days):
228
+ at = "/days/%d" % i
229
+ date = _jsv.show("" if day.get("date") is None else day["date"]) # the contract's show(day.date ?? "")
230
+ if date <= previous:
231
+ flag(at + "/date", "days must run forward without a repeat (%s after %s)" % (date, previous))
232
+ previous = date
233
+ findings.extend(_priced_row(day, at))
234
+ models = _usage_list(day.get("byModel"))
235
+ for j, m in enumerate(models):
236
+ findings.extend(_priced_row(m, "%s/byModel/%d" % (at, j)))
237
+ # The contract reads every count as `toNumber(x ?? 0)` and writes it with `show`: a string count is read as a
238
+ # number, and what is no number (an object, "abc") is NaN, which equals no sum, so it is a finding.
239
+ number = lambda row, f: _jsv.js_to_number(row.get(f)) # noqa: E731 - None and absent read as 0
240
+ if not models:
241
+ spent = any(number(day, f) > 0 for f in ("calls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")) \
242
+ or number(day, "costUsd") > 0
243
+ if spent:
244
+ flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
245
+ continue
246
+ for field in ("calls", "unpricedCalls", "unmeasuredCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
247
+ total = 0.0
248
+ for m in models:
249
+ total += number(m, field) # in order, in doubles, as JavaScript adds
250
+ if number(day, field) != total:
251
+ shown = _jsv.show(0 if day.get(field) is None else day[field])
252
+ flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, shown, _jsv.js_string(total)))
253
+ known = 0.0
254
+ for m in models:
255
+ money = _usage_money(m.get("costUsd"))
256
+ known += money if money is not None else 0.0
257
+ day_cost = _usage_money(day.get("costUsd"))
258
+ # The contract's !close(day, known): Infinity or NaN is never close, so it is a finding.
259
+ if day_cost is not None and not abs(day_cost - known) <= 0.000001 + 1e-9 * max(abs(day_cost), abs(known)):
260
+ flag(at + "/costUsd", "the day's cost is not the sum of its models' known costs")
261
+ # The window.
262
+ if days:
263
+ generated = _ftime.parse_rfc3339_ms(value.get("generatedAt"))
264
+ if generated != generated:
265
+ flag("/generatedAt", "generatedAt is an RFC 3339 date-time with an offset, so the window can be read")
266
+ else:
267
+ today = (generated // _DAY_MS) * _DAY_MS
268
+ oldest = today - (_USAGE_WINDOW_DAYS - 1) * _DAY_MS
269
+ for i, day in enumerate(days):
270
+ date = _utc_date_ms(day.get("date"))
271
+ if date != date:
272
+ flag("/days/%d/date" % i, "a day's date exists in its month")
273
+ elif date > today:
274
+ flag("/days/%d/date" % i, "a day is not after the UTC date of generatedAt")
275
+ elif date < oldest:
276
+ flag("/days/%d/date" % i, "a day is within the last %d UTC days of generatedAt" % _USAGE_WINDOW_DAYS)
277
+ # The projects.
278
+ if isinstance(value.get("byProject"), list):
279
+ seen: Dict[str, str] = {}
280
+ project_calls, project_cost = 0, 0.0
281
+ for p, project in enumerate(value["byProject"]):
282
+ if not isinstance(project, dict):
283
+ continue
284
+ at = "/byProject/%d" % p
285
+ normal = fabric_identifiers.normal_identifier(project.get("projectId"))
286
+ if normal is not None:
287
+ if normal in seen:
288
+ flag(at + "/projectId", "each project is one row: %s names the project %s already names" % (project["projectId"], seen[normal]))
289
+ else:
290
+ seen[normal] = project["projectId"]
291
+ project_calls += _usage_count(project.get("calls"))
292
+ project_cost += _usage_cost(project.get("costUsd"))
293
+ findings.extend(_priced_row(project, at))
294
+ runs: List[str] = []
295
+ run_calls, run_cost, run_priced = 0, 0.0, 0
296
+ for r, run in enumerate(_usage_list(project.get("taskRuns"))):
297
+ if isinstance(run.get("taskRunId"), str):
298
+ if run["taskRunId"] in runs:
299
+ flag("%s/taskRuns/%d/taskRunId" % (at, r), "each task run is one row within its project")
300
+ runs.append(run["taskRunId"])
301
+ run_calls += _usage_count(run.get("calls"))
302
+ run_cost += _usage_cost(run.get("costUsd"))
303
+ run_priced += max(0, _usage_count(run.get("calls")) - _usage_count(run.get("unpricedCalls")))
304
+ findings.extend(_priced_row(run, "%s/taskRuns/%d" % (at, r)))
305
+ project_priced = max(0, _usage_count(project.get("calls")) - _usage_count(project.get("unpricedCalls")))
306
+ known_cost = _usage_cost(project.get("costUsd")) == _usage_money(project.get("costUsd")) # as JavaScript holds it
307
+ if run_calls > _usage_count(project.get("calls")):
308
+ flag(at + "/taskRuns", "a project's task runs hold more calls than the project (%d > %d)" % (run_calls, _usage_count(project.get("calls"))))
309
+ elif run_priced > project_priced:
310
+ flag(at + "/taskRuns", "a project's task runs hold more priced calls than the project (%d > %d)" % (run_priced, project_priced))
311
+ elif known_cost and _exceeds(run_cost, _usage_cost(project.get("costUsd"))):
312
+ flag(at + "/taskRuns", "a project's task runs cost more than the project (%s > %s)" % (_usd(run_cost), _usd(_usage_cost(project.get("costUsd")))))
313
+ day_calls = sum(_usage_count(d.get("calls")) for d in days)
314
+ if project_calls > day_calls:
315
+ flag("/byProject", "the projects hold more calls than the days (%d > %d)" % (project_calls, day_calls))
316
+ day_cost_total = sum(_usage_cost(d.get("costUsd")) for d in days)
317
+ if _exceeds(project_cost, day_cost_total):
318
+ flag("/byProject", "the projects cost more than the days (%s > %s)" % (_usd(project_cost), _usd(day_cost_total)))
319
+ return findings
320
+
321
+
322
+ OPENROUTER_CATEGORIES = ("cli-agent", "ide-extension", "cloud-agent", "programming-app", "native-app-builder",
323
+ "creative-writing", "video-gen", "image-gen", "audio-gen", "writing-assistant", "general-chat",
324
+ "personal-agent", "legal", "roleplay", "game")
325
+
326
+
327
+ _ATTR_PRODUCT = re.compile(r"[a-z][a-z0-9-]{0,62}", re.ASCII)
328
+
329
+
330
+ _ATTR_TITLE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?", re.ASCII)
331
+
332
+
333
+ _ATTRIBUTION_HEADERS = ("http-referer", "referer", "x-openrouter-title", "x-title", "x-openrouter-categories")
334
+
335
+
336
+ def attribution_setting_findings(value: Any) -> List[Dict[str, str]]:
337
+ """FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories."""
338
+ if not isinstance(value, dict):
339
+ return [{"code": "FAC-SEM-098", "instancePath": "", "message": "an attribution setting is an object"}]
340
+ findings: List[Dict[str, str]] = []
341
+ flag = lambda at, message: findings.append({"code": "FAC-SEM-098", "instancePath": at, "message": message}) # noqa: E731
342
+ if not (isinstance(value.get("product"), str) and _ATTR_PRODUCT.fullmatch(value["product"])):
343
+ flag("/product", "the product is a lower-case slug, the last segment of its product page")
344
+ if not (isinstance(value.get("title"), str) and _ATTR_TITLE.fullmatch(value["title"])):
345
+ flag("/title", "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end")
346
+ if "categories" in value:
347
+ categories = value["categories"]
348
+ if not isinstance(categories, list):
349
+ flag("/categories", "categories are a list")
350
+ else:
351
+ if len(categories) > 2:
352
+ flag("/categories", "at most 2 categories per request")
353
+ seen: List[str] = []
354
+ for i, category in enumerate(categories):
355
+ if not (isinstance(category, str) and category in OPENROUTER_CATEGORIES):
356
+ flag("/categories/%d" % i, "a category is one OpenRouter recognizes; it drops others without an error")
357
+ elif category in seen:
358
+ flag("/categories/%d" % i, "each category once")
359
+ else:
360
+ seen.append(category)
361
+ return findings
362
+
363
+
364
+ def _is_openrouter(url: Any) -> bool:
365
+ """Whether a URL's WHATWG reading is https, host openrouter.ai, the default port and no credentials (`new URL`):
366
+ leading and trailing spaces and controls trimmed, tabs and newlines removed, backslashes read as slashes, the
367
+ slashes after the scheme optional, the host case-folded, port 443 the default."""
368
+ if not isinstance(url, str):
369
+ return False
370
+ text = url.strip("".join(chr(c) for c in range(0x21))).replace("\t", "").replace("\n", "").replace("\r", "")
371
+ if not text[:6].lower() == "https:":
372
+ return False
373
+ rest = text[6:].lstrip("/\\")
374
+ end = len(rest)
375
+ for stop in "/\\?#":
376
+ found = rest.find(stop)
377
+ if found != -1:
378
+ end = min(end, found)
379
+ authority = rest[:end]
380
+ if "@" in authority:
381
+ userinfo, authority = authority.rsplit("@", 1)
382
+ if userinfo not in ("", ":"):
383
+ return False
384
+ host, _, port = authority.partition(":")
385
+ if port and not (port.isdigit() and int(port) == 443):
386
+ return False
387
+ return host.lower() == "openrouter.ai"
388
+
389
+
390
+ def attribution_headers(setting: Any, request: Any) -> Dict[str, Any]:
391
+ """The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
392
+ `no-setting` (attribution is off by default), `invalid-setting` (FAC-SEM-098), `not-openrouter` (only
393
+ `https://openrouter.ai`), `forwarded` (only traffic the product originates), `user-set` (a request that already
394
+ carries an attribution header keeps the user's own). Only `HTTP-Referer`, `X-OpenRouter-Title` and
395
+ `X-OpenRouter-Categories`; it never reads, moves or returns a key."""
396
+ req = request if isinstance(request, dict) else {}
397
+ if setting is None:
398
+ return {"headers": {}, "none": "no-setting"}
399
+ if attribution_setting_findings(setting):
400
+ return {"headers": {}, "none": "invalid-setting"}
401
+ if not _is_openrouter(req.get("url")):
402
+ return {"headers": {}, "none": "not-openrouter"}
403
+ if req.get("originated") is not True:
404
+ return {"headers": {}, "none": "forwarded"}
405
+ present = [str(name).lower() for name in req["headers"]] if isinstance(req.get("headers"), dict) else []
406
+ if any(name in _ATTRIBUTION_HEADERS for name in present):
407
+ return {"headers": {}, "none": "user-set"}
408
+ headers = {"HTTP-Referer": "https://passioncode.ai/%s/" % setting["product"], "X-OpenRouter-Title": setting["title"]}
409
+ if setting.get("categories"):
410
+ headers["X-OpenRouter-Categories"] = ",".join(setting["categories"])
411
+ return {"headers": headers}
412
+
413
+
414
+ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
415
+ now: Optional[_dt.datetime] = None,
416
+ budget: Optional[Dict[str, Any]] = None,
417
+ budgets: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
418
+ """The `service-usage.schema.json` answer: the last 31 UTC days, oldest first, per provider
419
+ and model. Days without calls are omitted. A row whose calls are all unpriced has
420
+ `costUsd: None`; totals are the sums of the model rows (FAC-SEM-025).
421
+
422
+ ``budgets`` (DEC-0027) lists every spending limit the service applies, enforced or not. A
423
+ machine limit with a calendar ``period`` and no ``spentUsd`` is counted here from the receipts,
424
+ the way ``budget`` is, so the two never disagree; a per-order limit (``per_job``, ``approval``)
425
+ gets ``spentUsd: None``; every other limit states its own spend. A report whose limits break
426
+ FAC-SEM-031 is refused (``usage_limit_problems``)."""
427
+ now = now or _dt.datetime.now(_dt.timezone.utc)
428
+ receipts = list(receipts)
429
+ first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
430
+ last = now.strftime("%Y-%m-%d")
431
+ days: Dict[str, Dict[str, Dict[str, Any]]] = {}
432
+ bases: Dict[Tuple[str, str, str], set] = {}
433
+ for r in receipts:
434
+ date = str(r.get("at", ""))[:10]
435
+ if not (first <= date <= last):
436
+ continue
437
+ key = (r["provider"], r["model"], r.get("route") or "")
438
+ row = days.setdefault(date, {}).setdefault("%s\u0000%s\u0000%s" % key, {
439
+ "provider": r["provider"], "model": r["model"], "calls": 0, "unpricedCalls": 0,
440
+ "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0})
441
+ if r.get("route"):
442
+ row["route"] = r["route"] # DEC-0046: rows of one model on two routes stay apart
443
+ _add_totals(row, r)
444
+ bases.setdefault((date,) + key, set()).add(r.get("costBasis") or "unknown")
445
+ out_days = []
446
+ for date in sorted(days):
447
+ models = []
448
+ for row in days[date].values():
449
+ seen = bases[(date, row["provider"], row["model"], row.get("route") or "")]
450
+ row["costBasis"] = next(iter(seen)) if len(seen) == 1 else "mixed"
451
+ models.append(_close_totals(row))
452
+ models.sort(key=lambda m: (m["provider"], m["model"], m.get("route") or ""))
453
+ day = {"date": date, "calls": 0, "unpricedCalls": 0, "inputTokens": 0, "outputTokens": 0,
454
+ "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0}
455
+ for m in models:
456
+ for name in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
457
+ day[name] += m[name]
458
+ if m.get("unmeasuredCalls"):
459
+ day["unmeasuredCalls"] = day.get("unmeasuredCalls", 0) + m["unmeasuredCalls"]
460
+ day["_cost"] += m["costUsd"] or 0.0
461
+ day = _close_totals(day)
462
+ day["byModel"] = models
463
+ out_days.append(day)
464
+ report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
465
+ "generatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "currency": "USD", "days": out_days}
466
+ projects = _by_project(receipts, first, last)
467
+ if projects:
468
+ report["byProject"] = projects
469
+ if budget is not None:
470
+ if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
471
+ raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
472
+ report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]),
473
+ "spentUsd": _spent_in(out_days, budget["period"], last)}
474
+ if budgets is not None:
475
+ limits = []
476
+ for given in budgets:
477
+ if not isinstance(given, dict):
478
+ raise ServiceError("each limit in budgets is an object")
479
+ limit = dict(given)
480
+ if "spentUsd" not in limit:
481
+ if limit.get("kind") in PER_ORDER_KINDS:
482
+ limit["spentUsd"] = None
483
+ elif limit.get("scope") == "machine" and limit.get("period") in ("day", "month"):
484
+ limit["spentUsd"] = _spent_in(out_days, limit["period"], last)
485
+ elif limit.get("scope") == "machine" and _number(limit.get("windowSeconds")):
486
+ limit["spentUsd"] = _spent_since(receipts, now - _dt.timedelta(seconds=limit["windowSeconds"]), now)
487
+ if isinstance(limit.get("limitUsd"), int) and not isinstance(limit.get("limitUsd"), bool):
488
+ limit["limitUsd"] = float(limit["limitUsd"])
489
+ limits.append(limit)
490
+ report["budgets"] = limits
491
+ problems = usage_limit_problems(report)
492
+ if problems:
493
+ raise ServiceError("the report's limits are not coherent: " + "; ".join(problems))
494
+ return report
495
+
496
+
497
+ def _spent_since(receipts: List[Dict[str, Any]], start: _dt.datetime, end: _dt.datetime) -> Optional[float]:
498
+ """Spend in a rolling window, from the receipts' own times; None when every call in it is unpriced."""
499
+ lo, hi = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
500
+ inside = [r for r in receipts if lo < str(r.get("at", "")) <= hi]
501
+ if inside and all(r.get("costUsd") is None for r in inside):
502
+ return None
503
+ return round(sum(r.get("costUsd") or 0.0 for r in inside), 6)
504
+
505
+
506
+ def _spent_in(out_days: List[Dict[str, Any]], period: str, last: str) -> Optional[float]:
507
+ """Spend in the current UTC day or month; None when every call in it is unpriced."""
508
+ prefix = last if period == "day" else last[:7]
509
+ window = [d for d in out_days if d["date"].startswith(prefix)]
510
+ unknown = bool(window) and all(d["costUsd"] is None for d in window)
511
+ return None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
512
+
513
+
514
+ LIMIT_SCOPES = ("machine", "project", "pool", "job")
515
+
516
+
517
+ PER_ORDER_KINDS = ("per_job", "approval")
518
+
519
+
520
+ _LIMIT_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,63}$")
521
+
522
+
523
+ _LIMIT_KIND = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
524
+
525
+
526
+ _LIMIT_FIELDS = ("id", "scope", "subject", "kind", "period", "windowSeconds", "since", "limitUsd", "spentUsd", "enforced", "tripped")
527
+
528
+
529
+ _TIMESTAMP = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
530
+
531
+
532
+ def _is_timestamp(value: Any) -> bool:
533
+ """A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset."""
534
+ m = _TIMESTAMP.fullmatch(value) if isinstance(value, str) else None
535
+ if not m:
536
+ return False
537
+ year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
538
+ try:
539
+ _dt.date(year, month, day)
540
+ except ValueError:
541
+ return False
542
+ offset_ok = m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
543
+ return hour <= 23 and minute <= 59 and second <= 60 and offset_ok
544
+
545
+
546
+ def usage_limit_problems(report: Dict[str, Any]) -> List[str]:
547
+ """The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031: unique
548
+ ids, a subject exactly for project and pool scope, one window that agrees with the kind, per-order limits with
549
+ no window and no spend, only a relative limit with a null `limitUsd`, a tripped limit enforced, and a legacy
550
+ `budget` that repeats one enforced machine limit. A breach (spent above the limit) is not a problem: a report
551
+ truthfully shows one, and a host shows it first. An unknown kind is never refused."""
552
+ problems: List[str] = []
553
+ limits = report.get("budgets")
554
+ if limits is None:
555
+ return problems
556
+ if not isinstance(limits, list) or len(limits) > 64:
557
+ return ["budgets is a list of at most 64 limits"]
558
+ seen = set()
559
+ for i, limit in enumerate(limits):
560
+ at = "/budgets/%d" % i
561
+ if not isinstance(limit, dict):
562
+ problems.append("%s is not an object" % at)
563
+ continue
564
+ for key in limit:
565
+ if key not in _LIMIT_FIELDS:
566
+ problems.append("%s/%s is not a limit field" % (at, key))
567
+ for key in ("id", "scope", "kind", "limitUsd", "spentUsd", "enforced"):
568
+ if key not in limit:
569
+ problems.append("%s is missing %s" % (at, key))
570
+ if "id" in limit and not (isinstance(limit["id"], str) and _LIMIT_ID.fullmatch(limit["id"])):
571
+ problems.append("%s/id must match %s" % (at, _LIMIT_ID.pattern))
572
+ if "scope" in limit and limit["scope"] not in LIMIT_SCOPES:
573
+ problems.append("%s/scope must be one of %s" % (at, ", ".join(LIMIT_SCOPES)))
574
+ if "subject" in limit and not (isinstance(limit["subject"], str) and 1 <= len(limit["subject"]) <= 128):
575
+ problems.append("%s/subject is 1 to 128 characters" % at)
576
+ if "kind" in limit and not (isinstance(limit["kind"], str) and _LIMIT_KIND.fullmatch(limit["kind"])):
577
+ problems.append("%s/kind must match %s" % (at, _LIMIT_KIND.pattern))
578
+ if "period" in limit and limit["period"] not in ("day", "month"):
579
+ problems.append("%s/period is day or month" % at)
580
+ if "windowSeconds" in limit and not (isinstance(limit["windowSeconds"], int) and not isinstance(limit["windowSeconds"], bool)
581
+ and limit["windowSeconds"] >= 1):
582
+ problems.append("%s/windowSeconds is a whole number of seconds, at least 1" % at)
583
+ if "since" in limit and not _is_timestamp(limit["since"]):
584
+ problems.append("%s/since is a UTC timestamp" % at)
585
+ if "limitUsd" in limit and not (limit["limitUsd"] is None or (_number(limit["limitUsd"]) and limit["limitUsd"] > 0)):
586
+ problems.append("%s/limitUsd is a dollar amount above zero, or null" % at)
587
+ if "spentUsd" in limit and not (limit["spentUsd"] is None or (_number(limit["spentUsd"]) and limit["spentUsd"] >= 0)):
588
+ problems.append("%s/spentUsd is a dollar amount, zero or more, or null" % at)
589
+ for key in ("enforced", "tripped"):
590
+ if key in limit and not isinstance(limit[key], bool):
591
+ problems.append("%s/%s is true or false" % (at, key))
592
+
593
+ # FAC-SEM-031
594
+ ident = str(limit.get("id", ""))
595
+ if ident in seen:
596
+ problems.append("FAC-SEM-031 %s/id: limit id %s appears twice" % (at, ident))
597
+ seen.add(ident)
598
+ scope = limit.get("scope")
599
+ named = scope in ("project", "pool")
600
+ if named and "subject" not in limit:
601
+ problems.append("FAC-SEM-031 %s/subject: a %s limit names its %s in subject" % (at, scope, scope))
602
+ if not named and "subject" in limit:
603
+ problems.append("FAC-SEM-031 %s/subject: a %s limit has no subject" % (at, scope))
604
+ windows = [f for f in ("period", "windowSeconds", "since") if f in limit]
605
+ if len(windows) > 1:
606
+ problems.append("FAC-SEM-031 %s/%s: a limit has one window, not %s" % (at, windows[1], " and ".join(windows)))
607
+ kind = str(limit.get("kind", ""))
608
+ seconds = limit.get("windowSeconds") if _number(limit.get("windowSeconds")) else None
609
+ if kind in PER_ORDER_KINDS:
610
+ if windows:
611
+ problems.append("FAC-SEM-031 %s/%s: %s bounds one order and has no window" % (at, windows[0], kind))
612
+ if limit.get("spentUsd", 0) is not None:
613
+ problems.append("FAC-SEM-031 %s/spentUsd: %s bounds one order, so nothing is spent against it (null)" % (at, kind))
614
+ elif kind == "daily":
615
+ if not (limit.get("period") == "day" or seconds == 86400):
616
+ problems.append("FAC-SEM-031 %s/period: a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)" % at)
617
+ elif kind == "monthly":
618
+ if not (limit.get("period") == "month" or (seconds is not None and 28 * 86400 <= seconds <= 31 * 86400)):
619
+ problems.append("FAC-SEM-031 %s/period: a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)" % at)
620
+ elif kind == "velocity":
621
+ if seconds is None:
622
+ problems.append("FAC-SEM-031 %s/windowSeconds: a velocity limit counts a rolling window (windowSeconds)" % at)
623
+ elif kind in ("pool", "emergency"):
624
+ if not windows:
625
+ problems.append("FAC-SEM-031 %s/period: a %s limit names the window it counts (period, windowSeconds or since)" % (at, kind))
626
+ known = kind in PER_ORDER_KINDS or kind in ("daily", "monthly", "pool", "emergency")
627
+ if known and "limitUsd" in limit and limit["limitUsd"] is None:
628
+ problems.append("FAC-SEM-031 %s/limitUsd: a %s limit is a dollar amount; only a relative limit (velocity) may leave it null" % (at, kind))
629
+ if limit.get("tripped") is True and limit.get("enforced") is not True:
630
+ problems.append("FAC-SEM-031 %s/tripped: a limit that is not enforced cannot have stopped work" % at)
631
+
632
+ budget = report.get("budget")
633
+ if isinstance(budget, dict) and limits:
634
+ kind = "daily" if budget.get("period") == "day" else "monthly"
635
+
636
+ def same(x: Any, y: Any) -> bool:
637
+ if x is None or y is None:
638
+ return x is y
639
+ return _number(x) and _number(y) and abs(x - y) <= 0.000001
640
+
641
+ twin = any(isinstance(l, dict) and l.get("scope") == "machine" and l.get("enforced") is True and l.get("kind") == kind
642
+ and l.get("period") == budget.get("period") and same(l.get("limitUsd"), budget.get("limitUsd"))
643
+ and same(l.get("spentUsd"), budget.get("spentUsd")) for l in limits)
644
+ if not twin:
645
+ problems.append("FAC-SEM-031 /budget: budget repeats one enforced machine %s limit with period %s in budgets[], so old "
646
+ "and new readers agree" % (kind, budget.get("period")))
647
+ return problems
648
+
649
+
650
+ class JsonlUsageLedger:
651
+ """Usage receipts for services that keep none yet: append-only JSON lines, mode 0600, pruned
652
+ to the reported window so the file stays bounded (LC-12). One writer process — the service,
653
+ which holds its instance lock; the lock here orders that process's threads."""
654
+
655
+ def __init__(self, path: Path):
656
+ self.path = Path(path)
657
+ self._lock = threading.Lock()
658
+
659
+ def record(self, receipt: Dict[str, Any]) -> None:
660
+ line = json.dumps(receipt, separators=(",", ":")) + "\n"
661
+ with self._lock:
662
+ ensure_private_dir(self.path.parent)
663
+ fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND | _O_BINARY, 0o600)
664
+ try:
665
+ size = os.fstat(fd).st_size
666
+ if size and _read_at(fd, 1, size - 1) != b"\n":
667
+ line = "\n" + line # a killed writer left a torn line: never glue a receipt to it
668
+ os.write(fd, line.encode("utf-8"))
669
+ finally:
670
+ os.close(fd)
671
+
672
+ def receipts(self) -> List[Dict[str, Any]]:
673
+ if not self.path.exists():
674
+ return []
675
+ out = []
676
+ for line in self.path.read_text(encoding="utf-8").splitlines():
677
+ try:
678
+ out.append(json.loads(line))
679
+ except ValueError:
680
+ continue # a torn last line from a killed writer is skipped, not fatal
681
+ return out
682
+
683
+ def prune(self, now: Optional[_dt.datetime] = None) -> int:
684
+ """Drop receipts older than the reported window; returns how many were dropped."""
685
+ now = now or _dt.datetime.now(_dt.timezone.utc)
686
+ first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
687
+ with self._lock:
688
+ every = self.receipts()
689
+ kept = [r for r in every if str(r.get("at", ""))[:10] >= first]
690
+ dropped = len(every) - len(kept)
691
+ if dropped:
692
+ atomic_write(self.path, "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in kept).encode("utf-8"))
693
+ return dropped
694
+
695
+ def report(self, *, service_id: str, instance: str = "default", now: Optional[_dt.datetime] = None,
696
+ budget: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
697
+ return usage_report(self.receipts(), service_id=service_id, instance=instance, now=now, budget=budget)