@passioncode-ai/fabric-agent-adapter 0.26.0 → 0.27.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0 - 2026-10-13
4
+
5
+ The usage report's window and spend by project, and OpenRouter attribution for a product's own traffic (contract
6
+ DEC-0065, FAA-33). Pin unchanged at `a046f57`.
7
+
8
+ ### Added
9
+
10
+ - **Spend by project.** `make_usage_receipt` / `makeUsageReceipt` take `project_id` / `projectId`, a URI kept in its
11
+ normal form (DEC-0076), and `task_run_id` / `taskRunId`. `usage_report` / `usageReport` then write `byProject`: each
12
+ project once, each task run once within it, with calls, unpriced calls and cost. It names no caller, session or
13
+ person.
14
+ - **FAC-SEM-025 whole:** `usage_findings` / `usageFindings` check the arithmetic, the window (every day exists, is not
15
+ after `generatedAt`'s UTC date and is at most 30 days before it) and the projects. The projects' calls, priced calls
16
+ and known costs are bounded by the days' (CT-40).
17
+ - **OpenRouter attribution, off by default:** `attribution_headers` / `attributionHeaders` and
18
+ `attribution_setting_findings` / `attributionSettingFindings` (FAC-SEM-098). The answer adds `HTTP-Referer`,
19
+ `X-OpenRouter-Title` and `X-OpenRouter-Categories` only with a valid setting, only to `https://openrouter.ai` (read as
20
+ a browser reads the URL), only for traffic the product originates, and never over a user's own attribution.
21
+ - **`check_service.py`** `usage.report` reads each row's route (`platform-metered` included), the window and
22
+ `byProject`.
23
+
24
+ ### Evidence
25
+
26
+ - Both kits pass the contract's 34 attribution cases.
27
+ - Both kits give the contract's `usageRules` answer on 21 reports: its six positive usage fixtures and 15 edits of the
28
+ by-project one.
29
+ - 23 planted defects, all caught.
30
+ - The scenario eval `usage-by-project-and-attribution` was written before the prose.
31
+
3
32
  ## 0.26.0 - 2026-10-13
4
33
 
5
34
  Feed events carry machine-readable `data` (contract DEC-0070, FAA-35), and a service says which protocol revisions it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.26.0",
6
+ "version": "0.27.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.26.0"
8
+ version: "0.27.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
@@ -16,7 +16,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.26.0"
19
+ version: "0.27.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
22
  extension-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
@@ -5,6 +5,14 @@ Fabric Agent Contract DEC-0021, `docs/specification/service.md` → *Usage repor
5
5
  offer it, so Fabric Dashboards (Spend, MCP `spend`) and Fabric can show each agent's spend beside
6
6
  its health without knowing any provider.
7
7
 
8
+ ## Contents
9
+
10
+ - What to record
11
+ - What to answer
12
+ - By project (DEC-0065)
13
+ - OpenRouter attribution (DEC-0065)
14
+ - Verify
15
+
8
16
  ## What to record
9
17
 
10
18
  One **receipt per model call**, built from the numbers the provider returned:
@@ -78,9 +86,36 @@ Call `ledger.prune()` at start (or daily) to keep the file at the reported windo
78
86
  `JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
79
87
  next receipt never glues onto it.
80
88
 
89
+ ## By project (DEC-0065)
90
+
91
+ Pass `project_id` / `projectId` (the URI of the project the service ran the call for) and, optionally, `task_run_id` /
92
+ `taskRunId` (the service's own run id) to `make_usage_receipt` / `makeUsageReceipt`. The report then carries `byProject`:
93
+ each project once, by the normal form of its id (DEC-0076, so `URN:PROJECT:a` and `urn:project:a` are one row), with
94
+ its calls, unpriced calls, cost and `taskRuns`. A call for no project is in no row. It is the service's own run
95
+ attribution and names no caller, session or person. `usage_findings` / `usageFindings` port FAC-SEM-025 whole:
96
+ - the arithmetic;
97
+ - the window: every day exists, is not after `generatedAt`'s UTC date, and is at most 30 days before it;
98
+ - the projects: each project and task run once, and their calls, priced calls and known costs within the days' (CT-40).
99
+
100
+ A row's `route` may be `platform-metered`: the hosting platform's own metered models under the deployment's account.
101
+
102
+ ## OpenRouter attribution (DEC-0065)
103
+
104
+ A product credits its own OpenRouter traffic with `attribution_headers(setting, request)` / `attributionHeaders`. The
105
+ setting is `{product, title, categories?}`: a lower-case slug (the app is `https://passioncode.ai/<product>/`), a printable
106
+ title, and at most two of OpenRouter's recognized categories (FAC-SEM-098, `attribution_setting_findings`). Attribution
107
+ is off by default: without a setting nothing is sent. The answer adds `HTTP-Referer`, `X-OpenRouter-Title` and
108
+ `X-OpenRouter-Categories` only when all of these hold:
109
+ - the request goes to `https://openrouter.ai` (read as a browser reads a URL);
110
+ - the product itself originated the traffic;
111
+ - the request carries no attribution header of the user's own.
112
+
113
+ Otherwise it answers why not. It never reads, moves or returns a key.
114
+
81
115
  ## Verify
82
116
 
83
117
  `check_service.py <id>` reads a declared report: `usage.requires-token` (401 without the token)
84
- and `usage.report` (identity, USD, dates forward, null for all-unpriced rows, sums). The
118
+ and `usage.report` (identity, USD, dates forward, null for all-unpriced rows, sums, each row's route, the window and
119
+ `byProject`). The
85
120
  `sample_service.py` serves one. Fabric Dashboards → **Spend** shows it within a minute of opening
86
121
  the page.
@@ -880,6 +880,16 @@ def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
880
880
  known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)))
881
881
  if isinstance(day.get("costUsd"), (int, float)) and abs(day["costUsd"] - known) > 1e-6:
882
882
  out.append("%s: costUsd is not the sum of its models' known costs" % at)
883
+ for j, m in enumerate(models):
884
+ # DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
885
+ # own metered models (a listing in the cloud).
886
+ if "route" in m and m["route"] not in fs.USAGE_ROUTES:
887
+ out.append("%s.byModel[%d]: route %r is not subscription, key or platform-metered" % (at, j, m["route"]))
888
+ # DEC-0065: the window and the project attribution, as the contract's usageRules reads them (FAC-SEM-025).
889
+ for finding in fs.usage_findings(report):
890
+ path, message = finding["instancePath"], finding["message"]
891
+ if path.startswith(("/byProject", "/generatedAt")) or "generatedAt" in message or "exists in its month" in message:
892
+ out.append("%s: %s" % (path or "/", message))
883
893
  return out
884
894
 
885
895
 
@@ -9,6 +9,7 @@ import fs from 'node:fs';
9
9
  import net from 'node:net';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
+ import { normalIdentifier } from './fabric-identifiers.mjs';
12
13
  import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
13
14
 
14
15
  export const PROTOCOL = 'fabric-service/0.1';
@@ -1016,7 +1017,11 @@ const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTok
1016
1017
  * One model call, from the provider's own usage numbers. No prompt, output or caller.
1017
1018
  * `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
1018
1019
  */
1019
- export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route } = {}) {
1020
+ /**
1021
+ * One model call. `projectId` (a URI, kept in its normal form, DEC-0076) and `taskRunId` (the service's own run id) attribute
1022
+ * it to the project the service ran it for (DEC-0065); they never name a caller, session or person.
1023
+ */
1024
+ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route, projectId, taskRunId } = {}) {
1020
1025
  if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
1021
1026
  model = String(model ?? '').trim();
1022
1027
  if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
@@ -1026,7 +1031,34 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
1026
1031
  const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
1027
1032
  if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
1028
1033
  if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
1029
- return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }) };
1034
+ if (taskRunId !== undefined && projectId === undefined) throw new ServiceError('a task run belongs to a project: give projectId with taskRunId.');
1035
+ if (projectId !== undefined && !(typeof projectId === 'string' && /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(projectId))) throw new ServiceError('projectId is the URI of the project the service ran the call for.');
1036
+ if (taskRunId !== undefined && !(typeof taskRunId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(taskRunId))) throw new ServiceError("taskRunId is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.");
1037
+ return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }),
1038
+ ...(projectId === undefined ? {} : { projectId: normalIdentifier(projectId) }), ...(taskRunId === undefined ? {} : { taskRunId }) };
1039
+ }
1040
+
1041
+ /** DEC-0065's `byProject`: the window's calls by project (normal form, DEC-0076), each task run once within it. */
1042
+ function byProject(receipts, first, last) {
1043
+ const projects = new Map();
1044
+ const add = (row, r) => { row.calls += 1; if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd; };
1045
+ for (const r of receipts) {
1046
+ const date = String(r.at ?? '').slice(0, 10);
1047
+ if (date < first || date > last || !r.projectId) continue;
1048
+ if (!projects.has(r.projectId)) projects.set(r.projectId, { projectId: r.projectId, calls: 0, unpricedCalls: 0, cost: 0, runs: new Map() });
1049
+ const row = projects.get(r.projectId);
1050
+ add(row, r);
1051
+ if (r.taskRunId) {
1052
+ if (!row.runs.has(r.taskRunId)) row.runs.set(r.taskRunId, { taskRunId: r.taskRunId, calls: 0, unpricedCalls: 0, cost: 0 });
1053
+ add(row.runs.get(r.taskRunId), r);
1054
+ }
1055
+ }
1056
+ const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
1057
+ return [...projects.keys()].sort().map((key) => {
1058
+ const { runs, ...row } = projects.get(key);
1059
+ const taskRuns = [...runs.keys()].sort().map((id) => close(runs.get(id)));
1060
+ return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
1061
+ });
1030
1062
  }
1031
1063
 
1032
1064
  const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -1041,6 +1073,181 @@ function closeRow(row) {
1041
1073
  * model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
1042
1074
  * are the sums of the model rows (FAC-SEM-025).
1043
1075
  */
1076
+ // --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` --------------------------------
1077
+
1078
+ const usageCount = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : 0);
1079
+ const usageCost = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
1080
+ const exceeds = (a, b) => a - b > 0.000001 + 1e-9 * Math.max(Math.abs(a), Math.abs(b));
1081
+ const usd = (v) => String(Math.round(v * 1e6) / 1e6);
1082
+ const usageList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'object' && x !== null && !Array.isArray(x)) : []);
1083
+ const RFC3339_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
1084
+ function strictTime(value) {
1085
+ const m = typeof value === 'string' ? RFC3339_TIME.exec(value) : null;
1086
+ if (!m) return NaN;
1087
+ const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
1088
+ const date = new Date(0);
1089
+ date.setUTCFullYear(y, mo - 1, d);
1090
+ if (date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d || h > 23 || mi > 59 || s > 59) return NaN;
1091
+ return Date.parse(value);
1092
+ }
1093
+ function utcDateMs(value) {
1094
+ const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
1095
+ if (!m) return NaN;
1096
+ const [y, mo, d] = m.slice(1).map(Number);
1097
+ const ms = Date.UTC(y, mo - 1, d);
1098
+ const date = new Date(ms);
1099
+ return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d ? ms : NaN;
1100
+ }
1101
+ function pricedRow(row, at) {
1102
+ const calls = usageCount(row.calls), unpriced = usageCount(row.unpricedCalls);
1103
+ const flag = (p, message) => [{ code: 'FAC-SEM-025', instancePath: p, message }];
1104
+ if (unpriced > calls) return flag(`${at}/unpricedCalls`, 'more unpriced calls than calls');
1105
+ if (calls > 0 && unpriced === calls && row.costUsd !== null) return flag(`${at}/costUsd`, 'every call is unpriced, so the cost is unknown (null), not a number');
1106
+ if (unpriced < calls && row.costUsd === null) return flag(`${at}/costUsd`, 'priced calls carry a cost; null is only for a row with no priced call');
1107
+ return [];
1108
+ }
1109
+
1110
+ /**
1111
+ * FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic, the window (every
1112
+ * date exists, not after `generatedAt`'s UTC date, at most 30 days before it) and the project attribution (each project
1113
+ * once by its normal form, each task run once; calls, priced calls and known costs bounded). Never throws.
1114
+ */
1115
+ export function usageFindings(report) {
1116
+ const value = typeof report === 'object' && report !== null && !Array.isArray(report) ? report : {};
1117
+ const findings = [];
1118
+ const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-025', instancePath, message });
1119
+ const days = usageList(value.days);
1120
+ let previous = '';
1121
+ days.forEach((day, i) => {
1122
+ const at = `/days/${i}`;
1123
+ const date = String(day.date ?? '');
1124
+ if (date <= previous) flag(`${at}/date`, `days must run forward without a repeat (${date} after ${previous})`);
1125
+ previous = date;
1126
+ findings.push(...pricedRow(day, at));
1127
+ const models = usageList(day.byModel);
1128
+ models.forEach((m, j) => findings.push(...pricedRow(m, `${at}/byModel/${j}`)));
1129
+ if (!models.length) {
1130
+ const spent = ['calls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some((f) => Number(day[f] ?? 0) > 0) || Number(day.costUsd ?? 0) > 0;
1131
+ if (spent) flag(`${at}/byModel`, 'a day with calls, tokens or cost names the models they belong to');
1132
+ return;
1133
+ }
1134
+ for (const field of ['calls', 'unpricedCalls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
1135
+ const sum = models.reduce((n, m) => n + Number(m[field] ?? 0), 0);
1136
+ if (Number(day[field] ?? 0) !== sum) flag(`${at}/${field}`, `the day's ${field} is not the sum of its models (${String(day[field] ?? 0)} ≠ ${sum})`);
1137
+ }
1138
+ const known = models.map((m) => (typeof m.costUsd === 'number' ? m.costUsd : null)).filter((x) => x !== null).reduce((n, x) => n + x, 0);
1139
+ if (typeof day.costUsd === 'number' && Math.abs(day.costUsd - known) > 0.000001 + 1e-9 * Math.max(Math.abs(day.costUsd), Math.abs(known))) flag(`${at}/costUsd`, "the day's cost is not the sum of its models' known costs");
1140
+ });
1141
+ if (days.length) {
1142
+ const generated = strictTime(value.generatedAt);
1143
+ if (Number.isNaN(generated)) flag('/generatedAt', 'generatedAt is an RFC 3339 date-time with an offset, so the window can be read');
1144
+ else {
1145
+ const today = Math.floor(generated / 86_400_000) * 86_400_000;
1146
+ const oldest = today - 30 * 86_400_000;
1147
+ days.forEach((day, i) => {
1148
+ const date = utcDateMs(day.date);
1149
+ if (Number.isNaN(date)) flag(`/days/${i}/date`, "a day's date exists in its month");
1150
+ else if (date > today) flag(`/days/${i}/date`, 'a day is not after the UTC date of generatedAt');
1151
+ else if (date < oldest) flag(`/days/${i}/date`, 'a day is within the last 31 UTC days of generatedAt');
1152
+ });
1153
+ }
1154
+ }
1155
+ if (Array.isArray(value.byProject)) {
1156
+ const seen = new Map();
1157
+ let projectCalls = 0, projectCost = 0;
1158
+ value.byProject.forEach((project, p) => {
1159
+ if (typeof project !== 'object' || project === null || Array.isArray(project)) return;
1160
+ const at = `/byProject/${p}`;
1161
+ const normal = normalIdentifier(project.projectId);
1162
+ if (normal !== undefined) {
1163
+ if (seen.has(normal)) flag(`${at}/projectId`, `each project is one row: ${String(project.projectId)} names the project ${seen.get(normal)} already names`);
1164
+ else seen.set(normal, String(project.projectId));
1165
+ }
1166
+ projectCalls += usageCount(project.calls);
1167
+ projectCost += usageCost(project.costUsd);
1168
+ findings.push(...pricedRow(project, at));
1169
+ const runs = new Set();
1170
+ let runCalls = 0, runCost = 0, runPriced = 0;
1171
+ usageList(project.taskRuns).forEach((run, r) => {
1172
+ if (typeof run.taskRunId === 'string') {
1173
+ if (runs.has(run.taskRunId)) flag(`${at}/taskRuns/${r}/taskRunId`, 'each task run is one row within its project');
1174
+ runs.add(run.taskRunId);
1175
+ }
1176
+ runCalls += usageCount(run.calls);
1177
+ runCost += usageCost(run.costUsd);
1178
+ runPriced += Math.max(0, usageCount(run.calls) - usageCount(run.unpricedCalls));
1179
+ findings.push(...pricedRow(run, `${at}/taskRuns/${r}`));
1180
+ });
1181
+ const projectPriced = Math.max(0, usageCount(project.calls) - usageCount(project.unpricedCalls));
1182
+ if (runCalls > usageCount(project.calls)) flag(`${at}/taskRuns`, `a project's task runs hold more calls than the project (${runCalls} > ${usageCount(project.calls)})`);
1183
+ else if (runPriced > projectPriced) flag(`${at}/taskRuns`, `a project's task runs hold more priced calls than the project (${runPriced} > ${projectPriced})`);
1184
+ else if (usageCost(project.costUsd) === project.costUsd && exceeds(runCost, usageCost(project.costUsd))) flag(`${at}/taskRuns`, `a project's task runs cost more than the project (${usd(runCost)} > ${usd(usageCost(project.costUsd))})`);
1185
+ });
1186
+ const dayCalls = days.reduce((n, d) => n + usageCount(d.calls), 0);
1187
+ if (projectCalls > dayCalls) flag('/byProject', `the projects hold more calls than the days (${projectCalls} > ${dayCalls})`);
1188
+ const dayCost = days.reduce((n, d) => n + usageCost(d.costUsd), 0);
1189
+ if (exceeds(projectCost, dayCost)) flag('/byProject', `the projects cost more than the days (${usd(projectCost)} > ${usd(dayCost)})`);
1190
+ }
1191
+ return findings;
1192
+ }
1193
+
1194
+ // --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default ------------------------------
1195
+
1196
+ export const OPENROUTER_CATEGORIES = Object.freeze(['cli-agent', 'ide-extension', 'cloud-agent', 'programming-app', 'native-app-builder',
1197
+ 'creative-writing', 'video-gen', 'image-gen', 'audio-gen', 'writing-assistant', 'general-chat', 'personal-agent', 'legal', 'roleplay', 'game']);
1198
+ const ATTR_PRODUCT = /^[a-z][a-z0-9-]{0,62}$/;
1199
+ const ATTR_TITLE = /^[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?$/;
1200
+ const ATTRIBUTION_HEADER_NAMES = new Set(['http-referer', 'referer', 'x-openrouter-title', 'x-title', 'x-openrouter-categories']);
1201
+
1202
+ /** FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories. */
1203
+ export function attributionSettingFindings(value) {
1204
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return [{ code: 'FAC-SEM-098', instancePath: '', message: 'an attribution setting is an object' }];
1205
+ const findings = [];
1206
+ const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-098', instancePath, message });
1207
+ if (typeof value.product !== 'string' || !ATTR_PRODUCT.test(value.product)) flag('/product', 'the product is a lower-case slug, the last segment of its product page');
1208
+ if (typeof value.title !== 'string' || !ATTR_TITLE.test(value.title)) flag('/title', "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end");
1209
+ if (value.categories !== undefined) {
1210
+ if (!Array.isArray(value.categories)) flag('/categories', 'categories are a list');
1211
+ else {
1212
+ if (value.categories.length > 2) flag('/categories', 'at most 2 categories per request');
1213
+ const seen = new Set();
1214
+ value.categories.forEach((c, i) => {
1215
+ if (typeof c !== 'string' || !OPENROUTER_CATEGORIES.includes(c)) flag(`/categories/${i}`, 'a category is one OpenRouter recognizes; it drops others without an error');
1216
+ else if (seen.has(c)) flag(`/categories/${i}`, 'each category once');
1217
+ else seen.add(c);
1218
+ });
1219
+ }
1220
+ }
1221
+ return findings;
1222
+ }
1223
+
1224
+ function isOpenRouter(url) {
1225
+ try {
1226
+ const u = new URL(url);
1227
+ return u.protocol === 'https:' && u.hostname === 'openrouter.ai' && u.port === '' && u.username === '' && u.password === '';
1228
+ } catch {
1229
+ return false;
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
1235
+ * `no-setting` (off by default), `invalid-setting`, `not-openrouter`, `forwarded`, `user-set`. Only `HTTP-Referer`,
1236
+ * `X-OpenRouter-Title` and `X-OpenRouter-Categories`; it never reads, moves or returns a key.
1237
+ */
1238
+ export function attributionHeaders(setting, given) {
1239
+ const request = typeof given === 'object' && given !== null ? given : {};
1240
+ if (setting === undefined || setting === null) return { headers: {}, none: 'no-setting' };
1241
+ if (attributionSettingFindings(setting).length) return { headers: {}, none: 'invalid-setting' };
1242
+ if (typeof request.url !== 'string' || !isOpenRouter(request.url)) return { headers: {}, none: 'not-openrouter' };
1243
+ if (request.originated !== true) return { headers: {}, none: 'forwarded' };
1244
+ const present = typeof request.headers === 'object' && request.headers !== null ? Object.keys(request.headers).map((n) => n.toLowerCase()) : [];
1245
+ if (present.some((n) => ATTRIBUTION_HEADER_NAMES.has(n))) return { headers: {}, none: 'user-set' };
1246
+ const headers = { 'HTTP-Referer': `https://passioncode.ai/${setting.product}/`, 'X-OpenRouter-Title': setting.title };
1247
+ if (setting.categories?.length) headers['X-OpenRouter-Categories'] = setting.categories.join(',');
1248
+ return { headers };
1249
+ }
1250
+
1044
1251
  /**
1045
1252
  * `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
1046
1253
  * calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
@@ -1077,6 +1284,8 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
1077
1284
  return { ...closeRow(day), byModel: models };
1078
1285
  });
1079
1286
  const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
1287
+ const projects = byProject(receipts, first, last);
1288
+ if (projects.length) report.byProject = projects;
1080
1289
  if (budget !== undefined) {
1081
1290
  if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
1082
1291
  report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
@@ -1618,9 +1618,13 @@ _PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
1618
1618
  def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
1619
1619
  cache_read_tokens: int = 0, cache_write_tokens: int = 0,
1620
1620
  cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
1621
- at: Optional[str] = None, route: Optional[str] = None) -> Dict[str, Any]:
1621
+ at: Optional[str] = None, route: Optional[str] = None,
1622
+ project_id: Optional[str] = None, task_run_id: Optional[str] = None) -> Dict[str, Any]:
1622
1623
  """One model call, from the provider's own usage numbers. No prompt, output or caller.
1623
1624
 
1625
+ `project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
1626
+ call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
1627
+
1624
1628
  `cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
1625
1629
  never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
1626
1630
  (computed from a published price list); it is `unknown` when there is no cost."""
@@ -1645,9 +1649,55 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1645
1649
  "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1646
1650
  if route is not None:
1647
1651
  receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1652
+ if task_run_id is not None and project_id is None:
1653
+ raise ServiceError("a task run belongs to a project: give project_id with task_run_id.")
1654
+ if project_id is not None:
1655
+ import fabric_identifiers # noqa: E402 - beside this file
1656
+ if not (isinstance(project_id, str) and _URI.fullmatch(project_id)):
1657
+ raise ServiceError("project_id is the URI of the project the service ran the call for.")
1658
+ receipt["projectId"] = fabric_identifiers.normal_identifier(project_id)
1659
+ if task_run_id is not None:
1660
+ if not (isinstance(task_run_id, str) and _TASK_RUN_ID.fullmatch(task_run_id)):
1661
+ raise ServiceError("task_run_id is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.")
1662
+ receipt["taskRunId"] = task_run_id
1648
1663
  return receipt
1649
1664
 
1650
1665
 
1666
+ _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S+", re.ASCII)
1667
+ _TASK_RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", re.ASCII)
1668
+
1669
+
1670
+ def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[Dict[str, Any]]:
1671
+ """DEC-0065's `byProject`: the window's calls by the project the service ran them for, each project once (by its
1672
+ normal form, DEC-0076) and each task run once within it. A call for no project is in no row."""
1673
+ projects: Dict[str, Dict[str, Any]] = {}
1674
+ for r in receipts:
1675
+ if not (first <= str(r.get("at", ""))[:10] <= last) or not r.get("projectId"):
1676
+ continue
1677
+ row = projects.setdefault(r["projectId"], {"projectId": r["projectId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0, "_runs": {}})
1678
+ row["calls"] += 1
1679
+ if r.get("costUsd") is None:
1680
+ row["unpricedCalls"] += 1
1681
+ else:
1682
+ row["_cost"] += float(r["costUsd"])
1683
+ if r.get("taskRunId"):
1684
+ run = row["_runs"].setdefault(r["taskRunId"], {"taskRunId": r["taskRunId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0})
1685
+ run["calls"] += 1
1686
+ if r.get("costUsd") is None:
1687
+ run["unpricedCalls"] += 1
1688
+ else:
1689
+ run["_cost"] += float(r["costUsd"])
1690
+ out = []
1691
+ for key in sorted(projects):
1692
+ row = projects[key]
1693
+ runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
1694
+ row = _close_totals(row)
1695
+ if runs:
1696
+ row["taskRuns"] = runs
1697
+ out.append(row)
1698
+ return out
1699
+
1700
+
1651
1701
  def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
1652
1702
  row["calls"] += 1
1653
1703
  for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
@@ -1664,6 +1714,246 @@ def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
1664
1714
  return row
1665
1715
 
1666
1716
 
1717
+ # --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` -----------------------------
1718
+
1719
+ _USAGE_WINDOW_DAYS = 31
1720
+ _DAY_MS = 86_400_000
1721
+
1722
+
1723
+ def _usage_count(value: Any) -> int:
1724
+ return value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 2 ** 53 - 1 else 0
1725
+
1726
+
1727
+ def _usage_cost(value: Any) -> float:
1728
+ return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) and value == value and value >= 0 and value != float("inf") else 0.0
1729
+
1730
+
1731
+ def _exceeds(a: float, b: float) -> bool:
1732
+ return a - b > 0.000001 + 1e-9 * max(abs(a), abs(b))
1733
+
1734
+
1735
+ def _usd(value: float) -> str:
1736
+ rounded = round(value * 1e6) / 1e6
1737
+ return str(int(rounded)) if rounded == int(rounded) else repr(rounded)
1738
+
1739
+
1740
+ def _utc_date_ms(value: Any) -> float:
1741
+ match = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", value, re.ASCII) if isinstance(value, str) else None
1742
+ if not match:
1743
+ return float("nan")
1744
+ try:
1745
+ day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
1746
+ except ValueError:
1747
+ return float("nan")
1748
+ return float((day - _dt.date(1970, 1, 1)).days * _DAY_MS)
1749
+
1750
+
1751
+ def _usage_list(value: Any) -> List[Dict[str, Any]]:
1752
+ return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else []
1753
+
1754
+
1755
+ def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
1756
+ calls, unpriced = _usage_count(row.get("calls")), _usage_count(row.get("unpricedCalls"))
1757
+ flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
1758
+ if unpriced > calls:
1759
+ return flag(at + "/unpricedCalls", "more unpriced calls than calls")
1760
+ if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
1761
+ return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
1762
+ if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
1763
+ return flag(at + "/costUsd", "priced calls carry a cost; null is only for a row with no priced call")
1764
+ return []
1765
+
1766
+
1767
+ _MISSING_COST = object()
1768
+
1769
+
1770
+ def usage_findings(report: Any) -> List[Dict[str, str]]:
1771
+ """FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic of days and
1772
+ models, the window (every date exists, not after `generatedAt`'s UTC date, at most 30 days before it), and the
1773
+ project attribution (each project once by its normal form, each task run once; calls, priced calls and known costs
1774
+ bounded). Malformed input answers findings or none, never an exception."""
1775
+ import fabric_identifiers # noqa: E402
1776
+ import fabric_estate # noqa: E402 - the strict RFC 3339 reader
1777
+ value = report if isinstance(report, dict) else {}
1778
+ findings: List[Dict[str, str]] = []
1779
+ flag = lambda path, message: findings.append({"code": "FAC-SEM-025", "instancePath": path, "message": message}) # noqa: E731
1780
+ days = _usage_list(value.get("days"))
1781
+ # The arithmetic.
1782
+ previous = ""
1783
+ for i, day in enumerate(days):
1784
+ at = "/days/%d" % i
1785
+ date = day.get("date")
1786
+ date = "" if date is None else (date if isinstance(date, str) else json.dumps(date))
1787
+ if date <= previous:
1788
+ flag(at + "/date", "days must run forward without a repeat (%s after %s)" % (date, previous))
1789
+ previous = date
1790
+ findings.extend(_priced_row(day, at))
1791
+ models = _usage_list(day.get("byModel"))
1792
+ for j, m in enumerate(models):
1793
+ findings.extend(_priced_row(m, "%s/byModel/%d" % (at, j)))
1794
+ if not models:
1795
+ spent = any((day.get(f) or 0) > 0 for f in ("calls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
1796
+ if isinstance(day.get(f) or 0, (int, float))) or (isinstance(day.get("costUsd"), (int, float)) and day["costUsd"] > 0)
1797
+ if spent:
1798
+ flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
1799
+ continue
1800
+ for field in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1801
+ total = sum(m.get(field) or 0 for m in models if isinstance(m.get(field) or 0, (int, float)))
1802
+ if (day.get(field) or 0) != total:
1803
+ flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, day.get(field) or 0, total))
1804
+ known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)) and not isinstance(m.get("costUsd"), bool))
1805
+ day_cost = day.get("costUsd")
1806
+ if isinstance(day_cost, (int, float)) and not isinstance(day_cost, bool) and abs(day_cost - known) > 0.000001 + 1e-9 * max(abs(day_cost), abs(known)):
1807
+ flag(at + "/costUsd", "the day's cost is not the sum of its models' known costs")
1808
+ # The window.
1809
+ if days:
1810
+ generated = fabric_estate.estate_time(value.get("generatedAt"))
1811
+ if generated != generated:
1812
+ flag("/generatedAt", "generatedAt is an RFC 3339 date-time with an offset, so the window can be read")
1813
+ else:
1814
+ today = (generated // _DAY_MS) * _DAY_MS
1815
+ oldest = today - (_USAGE_WINDOW_DAYS - 1) * _DAY_MS
1816
+ for i, day in enumerate(days):
1817
+ date = _utc_date_ms(day.get("date"))
1818
+ if date != date:
1819
+ flag("/days/%d/date" % i, "a day's date exists in its month")
1820
+ elif date > today:
1821
+ flag("/days/%d/date" % i, "a day is not after the UTC date of generatedAt")
1822
+ elif date < oldest:
1823
+ flag("/days/%d/date" % i, "a day is within the last %d UTC days of generatedAt" % _USAGE_WINDOW_DAYS)
1824
+ # The projects.
1825
+ if isinstance(value.get("byProject"), list):
1826
+ seen: Dict[str, str] = {}
1827
+ project_calls, project_cost = 0, 0.0
1828
+ for p, project in enumerate(value["byProject"]):
1829
+ if not isinstance(project, dict):
1830
+ continue
1831
+ at = "/byProject/%d" % p
1832
+ normal = fabric_identifiers.normal_identifier(project.get("projectId"))
1833
+ if normal is not None:
1834
+ if normal in seen:
1835
+ flag(at + "/projectId", "each project is one row: %s names the project %s already names" % (project["projectId"], seen[normal]))
1836
+ else:
1837
+ seen[normal] = project["projectId"]
1838
+ project_calls += _usage_count(project.get("calls"))
1839
+ project_cost += _usage_cost(project.get("costUsd"))
1840
+ findings.extend(_priced_row(project, at))
1841
+ runs: List[str] = []
1842
+ run_calls, run_cost, run_priced = 0, 0.0, 0
1843
+ for r, run in enumerate(_usage_list(project.get("taskRuns"))):
1844
+ if isinstance(run.get("taskRunId"), str):
1845
+ if run["taskRunId"] in runs:
1846
+ flag("%s/taskRuns/%d/taskRunId" % (at, r), "each task run is one row within its project")
1847
+ runs.append(run["taskRunId"])
1848
+ run_calls += _usage_count(run.get("calls"))
1849
+ run_cost += _usage_cost(run.get("costUsd"))
1850
+ run_priced += max(0, _usage_count(run.get("calls")) - _usage_count(run.get("unpricedCalls")))
1851
+ findings.extend(_priced_row(run, "%s/taskRuns/%d" % (at, r)))
1852
+ project_priced = max(0, _usage_count(project.get("calls")) - _usage_count(project.get("unpricedCalls")))
1853
+ known_cost = isinstance(project.get("costUsd"), (int, float)) and not isinstance(project.get("costUsd"), bool) and _usage_cost(project.get("costUsd")) == project.get("costUsd")
1854
+ if run_calls > _usage_count(project.get("calls")):
1855
+ flag(at + "/taskRuns", "a project's task runs hold more calls than the project (%d > %d)" % (run_calls, _usage_count(project.get("calls"))))
1856
+ elif run_priced > project_priced:
1857
+ flag(at + "/taskRuns", "a project's task runs hold more priced calls than the project (%d > %d)" % (run_priced, project_priced))
1858
+ elif known_cost and _exceeds(run_cost, _usage_cost(project.get("costUsd"))):
1859
+ flag(at + "/taskRuns", "a project's task runs cost more than the project (%s > %s)" % (_usd(run_cost), _usd(_usage_cost(project.get("costUsd")))))
1860
+ day_calls = sum(_usage_count(d.get("calls")) for d in days)
1861
+ if project_calls > day_calls:
1862
+ flag("/byProject", "the projects hold more calls than the days (%d > %d)" % (project_calls, day_calls))
1863
+ day_cost_total = sum(_usage_cost(d.get("costUsd")) for d in days)
1864
+ if _exceeds(project_cost, day_cost_total):
1865
+ flag("/byProject", "the projects cost more than the days (%s > %s)" % (_usd(project_cost), _usd(day_cost_total)))
1866
+ return findings
1867
+
1868
+
1869
+ # --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default -----------------------------
1870
+
1871
+ OPENROUTER_CATEGORIES = ("cli-agent", "ide-extension", "cloud-agent", "programming-app", "native-app-builder",
1872
+ "creative-writing", "video-gen", "image-gen", "audio-gen", "writing-assistant", "general-chat",
1873
+ "personal-agent", "legal", "roleplay", "game")
1874
+ _ATTR_PRODUCT = re.compile(r"[a-z][a-z0-9-]{0,62}", re.ASCII)
1875
+ _ATTR_TITLE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?", re.ASCII)
1876
+ _ATTRIBUTION_HEADERS = ("http-referer", "referer", "x-openrouter-title", "x-title", "x-openrouter-categories")
1877
+
1878
+
1879
+ def attribution_setting_findings(value: Any) -> List[Dict[str, str]]:
1880
+ """FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories."""
1881
+ if not isinstance(value, dict):
1882
+ return [{"code": "FAC-SEM-098", "instancePath": "", "message": "an attribution setting is an object"}]
1883
+ findings: List[Dict[str, str]] = []
1884
+ flag = lambda at, message: findings.append({"code": "FAC-SEM-098", "instancePath": at, "message": message}) # noqa: E731
1885
+ if not (isinstance(value.get("product"), str) and _ATTR_PRODUCT.fullmatch(value["product"])):
1886
+ flag("/product", "the product is a lower-case slug, the last segment of its product page")
1887
+ if not (isinstance(value.get("title"), str) and _ATTR_TITLE.fullmatch(value["title"])):
1888
+ flag("/title", "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end")
1889
+ if "categories" in value:
1890
+ categories = value["categories"]
1891
+ if not isinstance(categories, list):
1892
+ flag("/categories", "categories are a list")
1893
+ else:
1894
+ if len(categories) > 2:
1895
+ flag("/categories", "at most 2 categories per request")
1896
+ seen: List[str] = []
1897
+ for i, category in enumerate(categories):
1898
+ if not (isinstance(category, str) and category in OPENROUTER_CATEGORIES):
1899
+ flag("/categories/%d" % i, "a category is one OpenRouter recognizes; it drops others without an error")
1900
+ elif category in seen:
1901
+ flag("/categories/%d" % i, "each category once")
1902
+ else:
1903
+ seen.append(category)
1904
+ return findings
1905
+
1906
+
1907
+ def _is_openrouter(url: Any) -> bool:
1908
+ """Whether a URL's WHATWG reading is https, host openrouter.ai, the default port and no credentials (`new URL`):
1909
+ leading and trailing spaces and controls trimmed, tabs and newlines removed, backslashes read as slashes, the
1910
+ slashes after the scheme optional, the host case-folded, port 443 the default."""
1911
+ if not isinstance(url, str):
1912
+ return False
1913
+ text = url.strip("".join(chr(c) for c in range(0x21))).replace("\t", "").replace("\n", "").replace("\r", "")
1914
+ if not text[:6].lower() == "https:":
1915
+ return False
1916
+ rest = text[6:].lstrip("/\\")
1917
+ end = len(rest)
1918
+ for stop in "/\\?#":
1919
+ found = rest.find(stop)
1920
+ if found != -1:
1921
+ end = min(end, found)
1922
+ authority = rest[:end]
1923
+ if "@" in authority:
1924
+ userinfo, authority = authority.rsplit("@", 1)
1925
+ if userinfo not in ("", ":"):
1926
+ return False
1927
+ host, _, port = authority.partition(":")
1928
+ if port and not (port.isdigit() and int(port) == 443):
1929
+ return False
1930
+ return host.lower() == "openrouter.ai"
1931
+
1932
+
1933
+ def attribution_headers(setting: Any, request: Any) -> Dict[str, Any]:
1934
+ """The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
1935
+ `no-setting` (attribution is off by default), `invalid-setting` (FAC-SEM-098), `not-openrouter` (only
1936
+ `https://openrouter.ai`), `forwarded` (only traffic the product originates), `user-set` (a request that already
1937
+ carries an attribution header keeps the user's own). Only `HTTP-Referer`, `X-OpenRouter-Title` and
1938
+ `X-OpenRouter-Categories`; it never reads, moves or returns a key."""
1939
+ req = request if isinstance(request, dict) else {}
1940
+ if setting is None:
1941
+ return {"headers": {}, "none": "no-setting"}
1942
+ if attribution_setting_findings(setting):
1943
+ return {"headers": {}, "none": "invalid-setting"}
1944
+ if not _is_openrouter(req.get("url")):
1945
+ return {"headers": {}, "none": "not-openrouter"}
1946
+ if req.get("originated") is not True:
1947
+ return {"headers": {}, "none": "forwarded"}
1948
+ present = [str(name).lower() for name in req["headers"]] if isinstance(req.get("headers"), dict) else []
1949
+ if any(name in _ATTRIBUTION_HEADERS for name in present):
1950
+ return {"headers": {}, "none": "user-set"}
1951
+ headers = {"HTTP-Referer": "https://passioncode.ai/%s/" % setting["product"], "X-OpenRouter-Title": setting["title"]}
1952
+ if setting.get("categories"):
1953
+ headers["X-OpenRouter-Categories"] = ",".join(setting["categories"])
1954
+ return {"headers": headers}
1955
+
1956
+
1667
1957
  def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
1668
1958
  now: Optional[_dt.datetime] = None,
1669
1959
  budget: Optional[Dict[str, Any]] = None,
@@ -1714,6 +2004,9 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
1714
2004
  out_days.append(day)
1715
2005
  report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
1716
2006
  "generatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "currency": "USD", "days": out_days}
2007
+ projects = _by_project(receipts, first, last)
2008
+ if projects:
2009
+ report["byProject"] = projects
1717
2010
  if budget is not None:
1718
2011
  if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
1719
2012
  raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.26.0"
8
+ version: "0.27.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.26.0"
21
+ version: "0.27.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.26.0"
63
+ VERSION = "0.27.0"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value