@passioncode-ai/fabric-agent-adapter 0.25.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 +64 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +18 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +36 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +20 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +339 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +470 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
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
|
+
|
|
32
|
+
## 0.26.0 - 2026-10-13
|
|
33
|
+
|
|
34
|
+
Feed events carry machine-readable `data` (contract DEC-0070, FAA-35), and a service says which protocol revisions it
|
|
35
|
+
accepts (DEC-0067, FAA-37). Both are in the pin `a046f57`, which does not move.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`data` on an event.**
|
|
40
|
+
- `make_event` / `makeEvent` and `JsonlEventLog.append` take `data={schema, value}`.
|
|
41
|
+
- `schema` is the absolute URI of a payload schema the service publishes.
|
|
42
|
+
- `value` holds at most 32 members. Each is a scalar (a string of at most 500 characters), a list of at most 32
|
|
43
|
+
scalars, or an object of at most 32 scalar members.
|
|
44
|
+
- FAC-SEM-101 refuses a string under a credential's name or of a credential's shape, at either level. The refusal names
|
|
45
|
+
the place, never the value.
|
|
46
|
+
- `feed_data_findings` / `feedDataFindings` and `feed_data_problems` / `feedDataProblems`.
|
|
47
|
+
- **Protocol revisions.**
|
|
48
|
+
- `PROTOCOL_REVISIONS` (the contract's register), `parse_revision`, `accepted_revisions` (N and N−1),
|
|
49
|
+
`negotiate_revision` / `negotiateRevision` (`schema-unsupported`, or the receiver's own `no-accepted-revisions`) and
|
|
50
|
+
`protocols_findings` / `protocolsFindings` (FAC-SEM-099).
|
|
51
|
+
- `build_well_known` / `buildWellKnown` take `protocols`. List them only once your hosts are pinned at or after
|
|
52
|
+
DEC-0067, because the document is closed for an older reader.
|
|
53
|
+
- **`check_service.py`** reads `well-known.protocols` and `events.data`.
|
|
54
|
+
|
|
55
|
+
### Evidence
|
|
56
|
+
|
|
57
|
+
Every case below is answered by the contract's own functions at `a046f57` (`vendor_vectors.mjs`):
|
|
58
|
+
- the 7 `negotiateRevision` cases and the register;
|
|
59
|
+
- FAC-SEM-099 over 14 lists;
|
|
60
|
+
- every events-page and well-known-protocols fixture as the catalogue lists it;
|
|
61
|
+
- FAC-SEM-101 over 10 pages.
|
|
62
|
+
|
|
63
|
+
N and N−1 cannot occur at this pin, because each protocol has one revision. That rule is held on a register with three
|
|
64
|
+
revisions. 20 planted defects were all caught. The scenario eval `feed-data-and-protocols` was written before the prose.
|
|
65
|
+
|
|
3
66
|
## 0.25.0 - 2026-10-13
|
|
4
67
|
|
|
5
68
|
Identifier equality by normal form (contract DEC-0076) in both kits, and the contract pin moves from `fe55fc6` to
|
|
@@ -711,7 +774,7 @@ DEC-0020 and DEC-0019 revisions stay valid.
|
|
|
711
774
|
code and reads the declared `minWidth`. Without Playwright every rule is `NOT_RUN`. It measures a settled
|
|
712
775
|
page: after the load it waits for the page's own requests to finish, and at each width for two frames,
|
|
713
776
|
250 ms and two more frames, so a header that grows when its data arrives is not measured between its new
|
|
714
|
-
size and the kit's reaction to it (seen once on
|
|
777
|
+
size and the kit's reaction to it (seen once on an adopting service's dashboard at 360 px: a false ED-5).
|
|
715
778
|
- **`check_service.py` rule `dashboard.declared`** (ED-9): PASS for a valid declaration (and names a newer
|
|
716
779
|
kit), FAIL for an invalid one, NOT_RUN for no dashboard or a legacy one.
|
|
717
780
|
- **`dashboard_extension()` / `dashboardExtension()`** in both kits, and `extensions` in `build_well_known` /
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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.
|
|
19
|
+
version: "0.27.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
22
|
extension-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
|
|
@@ -116,6 +116,24 @@ empty, or `null` for an empty log. Retain at least seven days or 1000 events.
|
|
|
116
116
|
path on the service origin, never an absolute URL. Optional SSE stream at
|
|
117
117
|
`surfaces.events.stream` carrying the same objects.
|
|
118
118
|
|
|
119
|
+
**Machine-readable `data` (DEC-0070).** An event may carry `data {schema, value}` beside its sentence: `schema` is the
|
|
120
|
+
absolute URI of a payload schema the service publishes; `value` is an object of at most 32 members, each a scalar (a
|
|
121
|
+
string of at most 500 characters, a number, a boolean, null), a list of at most 32 scalars, or an object of at most 32
|
|
122
|
+
scalar members. `text` stays the event: a host that ignores `data` loses nothing, and one that does not know the schema
|
|
123
|
+
treats `value` as opaque. No string in `value` sits under a credential's name or has a credential's shape
|
|
124
|
+
(`FAC-SEM-101`); personal data is the service's own duty. `make_event` / `makeEvent` (and `JsonlEventLog.append`) take
|
|
125
|
+
`data=` and refuse one that breaks either rule, naming the place, never the value; `check_service.py` checks the feed it
|
|
126
|
+
fetches (`events.data`). A host pinned before DEC-0070 that validates events against a closed copy refuses the member.
|
|
127
|
+
|
|
128
|
+
**Protocol revisions (DEC-0067).** The well-known document may list `protocols`, the revisions the service accepts: at
|
|
129
|
+
most N and N−1 per protocol, consecutive in the contract's register, each `<name>/<revision>` once, 1 to 64 entries
|
|
130
|
+
(`FAC-SEM-099`). `build_well_known` / `buildWellKnown` take `protocols=` and check it; list them only once your hosts
|
|
131
|
+
are pinned at or after DEC-0067, because the document is closed for an older reader. A service that receives a document
|
|
132
|
+
in a revision it does not read answers `negotiate_revision` / `negotiateRevision`'s `schema-unsupported` refusal before
|
|
133
|
+
reading it further (HTTP 400, or an MCP `isError` result's `structuredContent`); a receiver that accepts none answers its
|
|
134
|
+
own fault `no-accepted-revisions`. `PROTOCOL_REVISIONS` is the contract's register; `check_service.py` reads
|
|
135
|
+
`well-known.protocols`.
|
|
136
|
+
|
|
119
137
|
## Login code and the operator session — `POST /fabric/v1/login-code`
|
|
120
138
|
|
|
121
139
|
Token required → `{ "url": "/fabric/v1/login?code=<16–256 url-safe chars>", "expiresAt": "…" }`,
|
|
@@ -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
|
|
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.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -260,6 +260,11 @@ class Probe:
|
|
|
260
260
|
if "events" not in (wk.get("surfaces") or {}):
|
|
261
261
|
problems.append("surfaces.events missing")
|
|
262
262
|
self.add("well-known.shape", "FAIL" if problems else "PASS", "; ".join(problems) or "protocol, build, status, degraded, surfaces")
|
|
263
|
+
if "protocols" in wk:
|
|
264
|
+
# DEC-0067, FAC-SEM-099: the revisions it accepts, N and N−1 per protocol, each once.
|
|
265
|
+
findings = fs.protocols_findings(wk["protocols"])
|
|
266
|
+
self.add("well-known.protocols", "FAIL" if findings else "PASS",
|
|
267
|
+
"; ".join("%s %s" % (f["instancePath"], f["message"]) for f in findings) or ", ".join(wk["protocols"]))
|
|
263
268
|
self.configuration_rule()
|
|
264
269
|
svc = wk.get("service") or {}
|
|
265
270
|
answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
|
|
@@ -346,6 +351,11 @@ class Probe:
|
|
|
346
351
|
bad.append("%s (text is not a sentence)" % e.get("id"))
|
|
347
352
|
self.add("events.page", "FAIL" if bad else "PASS",
|
|
348
353
|
("bad events: " + ", ".join(bad)) if bad else "%d event(s), cursor %r" % (len(events), page["cursor"]))
|
|
354
|
+
# DEC-0070, FAC-SEM-101: an event's data never carries a credential; the finding names the place, never the value.
|
|
355
|
+
leaks = fs.feed_data_findings(page)
|
|
356
|
+
if any(isinstance(e, dict) and "data" in e for e in events) or leaks:
|
|
357
|
+
self.add("events.data", "FAIL" if leaks else "PASS",
|
|
358
|
+
"; ".join("%s: %s" % (f["instancePath"], f["message"]) for f in leaks) or "every event's data is free of credentials")
|
|
349
359
|
|
|
350
360
|
def usage_rules(self) -> None:
|
|
351
361
|
"""DEC-0021: a declared usage report requires the token and adds up (FAC-SEM-025)."""
|
|
@@ -870,6 +880,16 @@ def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
|
|
|
870
880
|
known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)))
|
|
871
881
|
if isinstance(day.get("costUsd"), (int, float)) and abs(day["costUsd"] - known) > 1e-6:
|
|
872
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))
|
|
873
893
|
return out
|
|
874
894
|
|
|
875
895
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -9,7 +9,8 @@ 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 {
|
|
12
|
+
import { normalIdentifier } from './fabric-identifiers.mjs';
|
|
13
|
+
import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
13
14
|
|
|
14
15
|
export const PROTOCOL = 'fabric-service/0.1';
|
|
15
16
|
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
|
|
@@ -818,7 +819,7 @@ export function configurationProblems(doc) {
|
|
|
818
819
|
return problems;
|
|
819
820
|
}
|
|
820
821
|
|
|
821
|
-
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration }) {
|
|
822
|
+
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols }) {
|
|
822
823
|
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
823
824
|
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
824
825
|
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
@@ -844,12 +845,132 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
844
845
|
if (problems.length) throw new ServiceError(`configuration: ${problems.join('; ')}.`);
|
|
845
846
|
doc.configuration = configuration;
|
|
846
847
|
}
|
|
848
|
+
if (protocols !== undefined) {
|
|
849
|
+
// DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so a
|
|
850
|
+
// service lists them only once its hosts are pinned at or after it.
|
|
851
|
+
const problems = protocolsFindings(protocols);
|
|
852
|
+
if (problems.length) throw new ServiceError(`protocols: ${problems.map((f) => f.message).join('; ')}.`);
|
|
853
|
+
doc.protocols = [...protocols];
|
|
854
|
+
}
|
|
847
855
|
return doc;
|
|
848
856
|
}
|
|
849
857
|
|
|
850
858
|
// An event about traced work carries traceId and spanId together (fabric-interop/0.1 C3.4 c);
|
|
851
859
|
// traceIds(traceparent) in fabric-interop.mjs gives both.
|
|
852
|
-
|
|
860
|
+
// --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data ---------------------------------------
|
|
861
|
+
|
|
862
|
+
/** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
|
|
863
|
+
export const PROTOCOL_REVISIONS = new Map(Object.entries({
|
|
864
|
+
'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
|
|
865
|
+
'fabric-activity': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
|
|
866
|
+
'fabric-interop': ['0.1'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
|
|
867
|
+
'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
|
|
868
|
+
'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
|
|
869
|
+
memory: ['0.1'], pipeline: ['0.1'], 'revocation-delta': ['1'], 'session-handoff': ['1'], 'window-lease': ['1'],
|
|
870
|
+
}));
|
|
871
|
+
export const MAX_PROTOCOLS = 64;
|
|
872
|
+
const REVISION = /^([a-z][a-z0-9.-]{0,63})\/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})$/;
|
|
873
|
+
|
|
874
|
+
/** `<name>/<revision>` split, or undefined. */
|
|
875
|
+
export function parseRevision(value) {
|
|
876
|
+
const m = typeof value === 'string' ? REVISION.exec(value) : null;
|
|
877
|
+
return m ? { name: m[1], revision: m[2] } : undefined;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** The revisions a receiver implementing `name` up to `newest` accepts: N and, when the register has one, N−1. */
|
|
881
|
+
export function acceptedRevisions(name, newest) {
|
|
882
|
+
const known = PROTOCOL_REVISIONS.get(name);
|
|
883
|
+
if (!known?.length) return [];
|
|
884
|
+
const top = newest === undefined ? known.length - 1 : known.indexOf(newest);
|
|
885
|
+
if (top < 0) return [];
|
|
886
|
+
return known.slice(Math.max(0, top - 1), top + 1).reverse().map((r) => `${name}/${r}`);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* A receiver's answer to a document naming `received` (DEC-0067, the contract's `negotiateRevision`): `{ok: true}`, the
|
|
891
|
+
* `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted revisions of that
|
|
892
|
+
* protocol (all of them when it names none the receiver reads), or `{fault: 'no-accepted-revisions'}`.
|
|
893
|
+
*/
|
|
894
|
+
export function negotiateRevision(received, accepted) {
|
|
895
|
+
const usable = [...new Set((Array.isArray(accepted) ? accepted : []).filter((e) => parseRevision(e) !== undefined))].slice(0, MAX_PROTOCOLS);
|
|
896
|
+
if (!usable.length) return { fault: 'no-accepted-revisions' };
|
|
897
|
+
if (typeof received === 'string' && usable.includes(received)) return { ok: true };
|
|
898
|
+
const parsed = parseRevision(received);
|
|
899
|
+
const same = parsed ? usable.filter((e) => parseRevision(e)?.name === parsed.name) : [];
|
|
900
|
+
return { error: { code: 'schema-unsupported', message: 'this receiver does not read that protocol revision',
|
|
901
|
+
data: { protocol: typeof received === 'string' ? Array.from(received).slice(0, 80).join('') : '', accepted: same.length ? same : usable } } };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** FAC-SEM-099 (DEC-0067): 1 to 64 entries, each once; at most N and N−1 of one protocol, consecutive in the register. */
|
|
905
|
+
export function protocolsFindings(protocols, prefix = '/protocols') {
|
|
906
|
+
const flag = (instancePath, message) => ({ code: 'FAC-SEM-099', instancePath, message });
|
|
907
|
+
if (!Array.isArray(protocols)) return [flag(prefix, 'protocols is a list of protocol revisions')];
|
|
908
|
+
if (protocols.length === 0 || protocols.length > MAX_PROTOCOLS) return [flag(prefix, `protocols lists 1 to ${MAX_PROTOCOLS} revisions`)];
|
|
909
|
+
const findings = [];
|
|
910
|
+
const byName = new Map();
|
|
911
|
+
const seen = new Set();
|
|
912
|
+
protocols.forEach((entry, i) => {
|
|
913
|
+
const at = `${prefix}/${i}`;
|
|
914
|
+
const parsed = parseRevision(entry);
|
|
915
|
+
if (!parsed) { findings.push(flag(at, 'a protocol revision is <name>/<revision>, such as fabric-service/0.1')); return; }
|
|
916
|
+
if (seen.has(entry)) { findings.push(flag(at, 'each protocol revision once')); return; }
|
|
917
|
+
seen.add(entry);
|
|
918
|
+
const known = PROTOCOL_REVISIONS.get(parsed.name);
|
|
919
|
+
if (known && !known.includes(parsed.revision)) { findings.push(flag(at, `${parsed.name} has no published revision ${parsed.revision}`)); return; }
|
|
920
|
+
const indices = byName.get(parsed.name) ?? [];
|
|
921
|
+
indices.push(known ? known.indexOf(parsed.revision) : -1);
|
|
922
|
+
byName.set(parsed.name, indices);
|
|
923
|
+
if (indices.length > 2) findings.push(flag(at, 'at most 2 revisions of one protocol: N and N−1'));
|
|
924
|
+
else if (known && indices.length === 2 && Math.abs(indices[0] - indices[1]) !== 1) findings.push(flag(at, 'two revisions of one protocol are N and N−1, consecutive in the register'));
|
|
925
|
+
});
|
|
926
|
+
return findings;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const SCHEMA_URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
930
|
+
const scalar = (v) => v === null || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && Array.from(v).length <= 500);
|
|
931
|
+
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
932
|
+
const pointerToken = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
933
|
+
|
|
934
|
+
/** FAC-SEM-101 over an events page (the contract's `feedDataFindings`): no credential's name or shape in an event's data. */
|
|
935
|
+
export function feedDataFindings(page) {
|
|
936
|
+
const findings = [];
|
|
937
|
+
const check = (named, member, at) => {
|
|
938
|
+
if (typeof member !== 'string') return;
|
|
939
|
+
if (named) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data puts no string under a credential's name" });
|
|
940
|
+
else if (secretValue(member) || secretUrl(member)) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data carries no value shaped like a credential" });
|
|
941
|
+
};
|
|
942
|
+
(Array.isArray(page?.events) ? page.events : []).forEach((event, i) => {
|
|
943
|
+
if (!isPlain(event) || !isPlain(event.data) || !isPlain(event.data.value)) return;
|
|
944
|
+
for (const [key, member] of Object.entries(event.data.value)) {
|
|
945
|
+
const at = `/events/${i}/data/value/${pointerToken(key)}`;
|
|
946
|
+
const named = credentialName(key);
|
|
947
|
+
check(named, member, at);
|
|
948
|
+
if (Array.isArray(member)) member.forEach((item, j) => check(named, item, `${at}/${j}`));
|
|
949
|
+
else if (isPlain(member)) for (const [inner, leaf] of Object.entries(member)) check(named || credentialName(inner), leaf, `${at}/${pointerToken(inner)}`);
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
return findings;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101. */
|
|
956
|
+
export function feedDataProblems(data) {
|
|
957
|
+
if (!isPlain(data) || Object.keys(data).length !== 2 || !('schema' in data) || !('value' in data)) return ['data is {schema, value}'];
|
|
958
|
+
const problems = [];
|
|
959
|
+
if (!(typeof data.schema === 'string' && SCHEMA_URI.test(data.schema))) problems.push('data.schema is the absolute URI of a payload schema the service publishes');
|
|
960
|
+
const value = data.value;
|
|
961
|
+
if (!isPlain(value) || Object.keys(value).length > 32) return [...problems, 'data.value is an object of at most 32 members'];
|
|
962
|
+
for (const [key, member] of Object.entries(value)) {
|
|
963
|
+
if (scalar(member)) continue;
|
|
964
|
+
if (Array.isArray(member) && member.length <= 32 && member.every(scalar)) continue;
|
|
965
|
+
if (isPlain(member) && Object.keys(member).length <= 32 && Object.values(member).every(scalar)) continue;
|
|
966
|
+
problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members`);
|
|
967
|
+
}
|
|
968
|
+
if (problems.length) return problems;
|
|
969
|
+
return feedDataFindings({ events: [{ data }] }).map((f) => `FAC-SEM-101: ${f.instancePath.replace('/events/0', '')} ${f.message}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** One activity event. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
|
|
973
|
+
export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId, data } = {}) {
|
|
853
974
|
if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
|
|
854
975
|
if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
|
|
855
976
|
const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
@@ -862,6 +983,11 @@ export function makeEvent(id, at, kind, level, text, { subject, link, notify, tr
|
|
|
862
983
|
if (link) event.link = link;
|
|
863
984
|
if (notify) event.notify = true;
|
|
864
985
|
if (traceId !== undefined) Object.assign(event, { traceId, spanId });
|
|
986
|
+
if (data !== undefined) {
|
|
987
|
+
const problems = feedDataProblems(data);
|
|
988
|
+
if (problems.length) throw new ServiceError(`Event data: ${problems.join('; ')}.`);
|
|
989
|
+
event.data = { schema: data.schema, value: data.value };
|
|
990
|
+
}
|
|
865
991
|
return event;
|
|
866
992
|
}
|
|
867
993
|
|
|
@@ -891,7 +1017,11 @@ const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTok
|
|
|
891
1017
|
* One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
892
1018
|
* `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
|
|
893
1019
|
*/
|
|
894
|
-
|
|
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 } = {}) {
|
|
895
1025
|
if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
|
|
896
1026
|
model = String(model ?? '').trim();
|
|
897
1027
|
if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
|
|
@@ -901,7 +1031,34 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
|
|
|
901
1031
|
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
902
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.');
|
|
903
1033
|
if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
|
|
904
|
-
|
|
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
|
+
});
|
|
905
1062
|
}
|
|
906
1063
|
|
|
907
1064
|
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
@@ -916,6 +1073,181 @@ function closeRow(row) {
|
|
|
916
1073
|
* model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
|
|
917
1074
|
* are the sums of the model rows (FAC-SEM-025).
|
|
918
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
|
+
|
|
919
1251
|
/**
|
|
920
1252
|
* `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
|
|
921
1253
|
* calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
|
|
@@ -952,6 +1284,8 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
|
|
|
952
1284
|
return { ...closeRow(day), byModel: models };
|
|
953
1285
|
});
|
|
954
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;
|
|
955
1289
|
if (budget !== undefined) {
|
|
956
1290
|
if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
|
|
957
1291
|
report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -1305,7 +1305,8 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
1305
1305
|
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
1306
1306
|
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
1307
1307
|
extensions: Optional[Dict[str, Any]] = None,
|
|
1308
|
-
configuration: Optional[Dict[str, Any]] = None
|
|
1308
|
+
configuration: Optional[Dict[str, Any]] = None,
|
|
1309
|
+
protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
1309
1310
|
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
1310
1311
|
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
1311
1312
|
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
@@ -1339,15 +1340,182 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
1339
1340
|
if problems:
|
|
1340
1341
|
raise ServiceError("configuration: %s." % "; ".join(problems))
|
|
1341
1342
|
doc["configuration"] = configuration
|
|
1343
|
+
if protocols is not None:
|
|
1344
|
+
# DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so
|
|
1345
|
+
# a service lists them only once its hosts are pinned at or after it.
|
|
1346
|
+
problems = protocols_findings(list(protocols))
|
|
1347
|
+
if problems:
|
|
1348
|
+
raise ServiceError("protocols: %s." % "; ".join(f["message"] for f in problems))
|
|
1349
|
+
doc["protocols"] = list(protocols)
|
|
1342
1350
|
return doc
|
|
1343
1351
|
|
|
1344
1352
|
|
|
1353
|
+
# --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data --------------------------------------
|
|
1354
|
+
|
|
1355
|
+
#: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
|
|
1356
|
+
#: holds it equal to the contract's register at the pin.
|
|
1357
|
+
PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
|
|
1358
|
+
"access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
|
|
1359
|
+
"fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
|
|
1360
|
+
"fabric-interop": ("0.1",), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
|
|
1361
|
+
"fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
|
|
1362
|
+
"fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
|
|
1363
|
+
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pipeline": ("0.1",),
|
|
1364
|
+
"revocation-delta": ("1",), "session-handoff": ("1",), "window-lease": ("1",),
|
|
1365
|
+
}
|
|
1366
|
+
MAX_PROTOCOLS = 64
|
|
1367
|
+
_REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def parse_revision(value: Any) -> Optional[Tuple[str, str]]:
|
|
1371
|
+
"""`<name>/<revision>` split, or None."""
|
|
1372
|
+
match = _REVISION.fullmatch(value) if isinstance(value, str) else None
|
|
1373
|
+
return (match.group(1), match.group(2)) if match else None
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def accepted_revisions(name: str, newest: Optional[str] = None) -> List[str]:
|
|
1377
|
+
"""The revisions a receiver implementing ``name`` up to ``newest`` accepts: N and, when the register has one, N−1."""
|
|
1378
|
+
known = PROTOCOL_REVISIONS.get(name, ())
|
|
1379
|
+
if not known:
|
|
1380
|
+
return []
|
|
1381
|
+
top = len(known) - 1 if newest is None else (known.index(newest) if newest in known else -1)
|
|
1382
|
+
if top < 0:
|
|
1383
|
+
return []
|
|
1384
|
+
return ["%s/%s" % (name, r) for r in reversed(known[max(0, top - 1):top + 1])]
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def negotiate_revision(received: Any, accepted: Sequence[Any]) -> Dict[str, Any]:
|
|
1388
|
+
"""A receiver's answer to a document naming ``received`` (DEC-0067, the contract's `negotiateRevision`):
|
|
1389
|
+
``{"ok": True}``; the `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted
|
|
1390
|
+
revisions of that protocol (all of them when it names none the receiver reads); or ``{"fault":
|
|
1391
|
+
"no-accepted-revisions"}``, the receiver's own, when it accepts no well-formed revision. Revisions compare exactly."""
|
|
1392
|
+
usable: List[str] = []
|
|
1393
|
+
for entry in accepted if isinstance(accepted, (list, tuple)) else []:
|
|
1394
|
+
if parse_revision(entry) and entry not in usable:
|
|
1395
|
+
usable.append(entry)
|
|
1396
|
+
usable = usable[:MAX_PROTOCOLS]
|
|
1397
|
+
if not usable:
|
|
1398
|
+
return {"fault": "no-accepted-revisions"}
|
|
1399
|
+
if isinstance(received, str) and received in usable:
|
|
1400
|
+
return {"ok": True}
|
|
1401
|
+
parsed = parse_revision(received)
|
|
1402
|
+
same = [e for e in usable if parsed and parse_revision(e)[0] == parsed[0]]
|
|
1403
|
+
return {"error": {"code": "schema-unsupported", "message": "this receiver does not read that protocol revision",
|
|
1404
|
+
"data": {"protocol": received[:80] if isinstance(received, str) else "", "accepted": same or usable}}}
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def protocols_findings(protocols: Any, prefix: str = "/protocols") -> List[Dict[str, str]]:
|
|
1408
|
+
"""FAC-SEM-099 (DEC-0067): 1 to 64 entries, each `<name>/<revision>` once; at most two revisions of one protocol (an
|
|
1409
|
+
unpublished one refused and not counted), and two of a contract protocol are N and N−1, consecutive in the register."""
|
|
1410
|
+
flag = lambda at, message: {"code": "FAC-SEM-099", "instancePath": at, "message": message} # noqa: E731
|
|
1411
|
+
if not isinstance(protocols, list):
|
|
1412
|
+
return [flag(prefix, "protocols is a list of protocol revisions")]
|
|
1413
|
+
if not 1 <= len(protocols) <= MAX_PROTOCOLS:
|
|
1414
|
+
return [flag(prefix, "protocols lists 1 to %d revisions" % MAX_PROTOCOLS)]
|
|
1415
|
+
findings: List[Dict[str, str]] = []
|
|
1416
|
+
by_name: Dict[str, List[int]] = {}
|
|
1417
|
+
seen: List[str] = []
|
|
1418
|
+
for i, entry in enumerate(protocols):
|
|
1419
|
+
at = "%s/%d" % (prefix, i)
|
|
1420
|
+
parsed = parse_revision(entry)
|
|
1421
|
+
if not parsed:
|
|
1422
|
+
findings.append(flag(at, "a protocol revision is <name>/<revision>, such as fabric-service/0.1"))
|
|
1423
|
+
continue
|
|
1424
|
+
if entry in seen:
|
|
1425
|
+
findings.append(flag(at, "each protocol revision once"))
|
|
1426
|
+
continue
|
|
1427
|
+
seen.append(entry)
|
|
1428
|
+
known = PROTOCOL_REVISIONS.get(parsed[0])
|
|
1429
|
+
if known and parsed[1] not in known:
|
|
1430
|
+
findings.append(flag(at, "%s has no published revision %s" % parsed))
|
|
1431
|
+
continue
|
|
1432
|
+
indices = by_name.setdefault(parsed[0], [])
|
|
1433
|
+
indices.append(known.index(parsed[1]) if known else -1)
|
|
1434
|
+
if len(indices) > 2:
|
|
1435
|
+
findings.append(flag(at, "at most 2 revisions of one protocol: N and N−1"))
|
|
1436
|
+
elif known and len(indices) == 2 and abs(indices[0] - indices[1]) != 1:
|
|
1437
|
+
findings.append(flag(at, "two revisions of one protocol are N and N−1, consecutive in the register"))
|
|
1438
|
+
return findings
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
_SCHEMA_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+", re.ASCII)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _scalar(value: Any) -> bool:
|
|
1445
|
+
return value is None or isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= 500)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def feed_data_problems(data: Any) -> List[str]:
|
|
1449
|
+
"""DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101: no string in `value`, at either
|
|
1450
|
+
level, under a credential's name or of a credential's shape (named by its place, never its value)."""
|
|
1451
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector
|
|
1452
|
+
if not isinstance(data, dict) or set(data) != {"schema", "value"}:
|
|
1453
|
+
return ["data is {schema, value}"]
|
|
1454
|
+
problems: List[str] = []
|
|
1455
|
+
if not (isinstance(data["schema"], str) and _SCHEMA_URI.fullmatch(data["schema"])):
|
|
1456
|
+
problems.append("data.schema is the absolute URI of a payload schema the service publishes")
|
|
1457
|
+
value = data["value"]
|
|
1458
|
+
if not isinstance(value, dict) or len(value) > 32:
|
|
1459
|
+
return problems + ["data.value is an object of at most 32 members"]
|
|
1460
|
+
for key, member in value.items():
|
|
1461
|
+
if _scalar(member):
|
|
1462
|
+
continue
|
|
1463
|
+
if isinstance(member, list) and len(member) <= 32 and all(_scalar(m) for m in member):
|
|
1464
|
+
continue
|
|
1465
|
+
if isinstance(member, dict) and len(member) <= 32 and all(_scalar(m) for m in member.values()):
|
|
1466
|
+
continue
|
|
1467
|
+
problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members" % key)
|
|
1468
|
+
if problems:
|
|
1469
|
+
return problems
|
|
1470
|
+
for finding in feed_data_findings({"events": [{"data": data}]}):
|
|
1471
|
+
problems.append("FAC-SEM-101: %s %s" % (finding["instancePath"].replace("/events/0", ""), finding["message"]))
|
|
1472
|
+
return problems
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def _pointer_token(name: str) -> str:
|
|
1476
|
+
return str(name).replace("~", "~0").replace("/", "~1")
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def feed_data_findings(page: Any) -> List[Dict[str, str]]:
|
|
1480
|
+
"""FAC-SEM-101 over an events page (the contract's `feedDataFindings`): a list's items and an object's members sit
|
|
1481
|
+
under the outer name too; a number is a count, never a credential."""
|
|
1482
|
+
import fabric_secrets # noqa: E402
|
|
1483
|
+
findings: List[Dict[str, str]] = []
|
|
1484
|
+
|
|
1485
|
+
def check(named: bool, member: Any, at: str) -> None:
|
|
1486
|
+
if not isinstance(member, str):
|
|
1487
|
+
return
|
|
1488
|
+
if named:
|
|
1489
|
+
findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data puts no string under a credential's name"})
|
|
1490
|
+
elif fabric_secrets.secret_value(member) or fabric_secrets.secret_url(member):
|
|
1491
|
+
findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data carries no value shaped like a credential"})
|
|
1492
|
+
|
|
1493
|
+
events = page.get("events") if isinstance(page, dict) else None
|
|
1494
|
+
for i, event in enumerate(events if isinstance(events, list) else []):
|
|
1495
|
+
data = event.get("data") if isinstance(event, dict) else None
|
|
1496
|
+
value = data.get("value") if isinstance(data, dict) else None
|
|
1497
|
+
if not isinstance(value, dict):
|
|
1498
|
+
continue
|
|
1499
|
+
for key, member in value.items():
|
|
1500
|
+
at = "/events/%d/data/value/%s" % (i, _pointer_token(key))
|
|
1501
|
+
named = fabric_secrets.credential_name(key)
|
|
1502
|
+
check(named, member, at)
|
|
1503
|
+
if isinstance(member, list):
|
|
1504
|
+
for j, item in enumerate(member):
|
|
1505
|
+
check(named, item, "%s/%d" % (at, j))
|
|
1506
|
+
elif isinstance(member, dict):
|
|
1507
|
+
for inner, leaf in member.items():
|
|
1508
|
+
check(named or fabric_secrets.credential_name(inner), leaf, "%s/%s" % (at, _pointer_token(inner)))
|
|
1509
|
+
return findings
|
|
1510
|
+
|
|
1511
|
+
|
|
1345
1512
|
# --- events ------------------------------------------------------------------
|
|
1346
1513
|
|
|
1347
1514
|
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
1348
1515
|
link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
|
|
1349
|
-
span_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1350
|
-
"""One activity event: one sentence a person reads, never a machine id.
|
|
1516
|
+
span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1517
|
+
"""One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
|
|
1518
|
+
fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
|
|
1351
1519
|
|
|
1352
1520
|
An event about traced work carries its trace as a pair, `trace_id` and `span_id`
|
|
1353
1521
|
(fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
|
|
@@ -1373,6 +1541,11 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
|
|
|
1373
1541
|
event["notify"] = True
|
|
1374
1542
|
if trace_id is not None:
|
|
1375
1543
|
event["traceId"], event["spanId"] = trace_id, span_id
|
|
1544
|
+
if data is not None:
|
|
1545
|
+
problems = feed_data_problems(data)
|
|
1546
|
+
if problems:
|
|
1547
|
+
raise ServiceError("Event data: %s." % "; ".join(problems))
|
|
1548
|
+
event["data"] = {"schema": data["schema"], "value": data["value"]}
|
|
1376
1549
|
return event
|
|
1377
1550
|
|
|
1378
1551
|
|
|
@@ -1445,9 +1618,13 @@ _PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
|
|
|
1445
1618
|
def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
|
|
1446
1619
|
cache_read_tokens: int = 0, cache_write_tokens: int = 0,
|
|
1447
1620
|
cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
|
|
1448
|
-
at: Optional[str] = None, route: Optional[str] = None
|
|
1621
|
+
at: Optional[str] = None, route: Optional[str] = None,
|
|
1622
|
+
project_id: Optional[str] = None, task_run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1449
1623
|
"""One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
1450
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
|
+
|
|
1451
1628
|
`cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
|
|
1452
1629
|
never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
|
|
1453
1630
|
(computed from a published price list); it is `unknown` when there is no cost."""
|
|
@@ -1472,9 +1649,55 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
|
|
|
1472
1649
|
"costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
|
|
1473
1650
|
if route is not None:
|
|
1474
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
|
|
1475
1663
|
return receipt
|
|
1476
1664
|
|
|
1477
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
|
+
|
|
1478
1701
|
def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
|
|
1479
1702
|
row["calls"] += 1
|
|
1480
1703
|
for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
@@ -1491,6 +1714,246 @@ def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1491
1714
|
return row
|
|
1492
1715
|
|
|
1493
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
|
+
|
|
1494
1957
|
def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
|
|
1495
1958
|
now: Optional[_dt.datetime] = None,
|
|
1496
1959
|
budget: Optional[Dict[str, Any]] = None,
|
|
@@ -1541,6 +2004,9 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
|
|
|
1541
2004
|
out_days.append(day)
|
|
1542
2005
|
report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
|
|
1543
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
|
|
1544
2010
|
if budget is not None:
|
|
1545
2011
|
if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
|
|
1546
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.
|
|
8
|
+
version: "0.27.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
|
|
11
11
|
---
|
|
@@ -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.
|
|
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
|