@passioncode-ai/fabric-agent-adapter 0.6.3 → 0.8.1
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 +132 -1
- package/COMMERCIAL-LICENSE.md +1 -1
- package/README.md +29 -6
- package/bin/fabric-agent-adapter.js +18 -0
- 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 +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +77 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +85 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +7 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Usage report — what the agent spent (DEC-0021)
|
|
2
|
+
|
|
3
|
+
Fabric Agent Contract DEC-0021, `docs/specification/service.md` → *Usage report*, schema
|
|
4
|
+
`schemas/service-usage.schema.json`. Optional. A service that calls paid models or tools should
|
|
5
|
+
offer it, so Fabric Dashboards (Spend, MCP `spend`) and Fabric can show each agent's spend beside
|
|
6
|
+
its health without knowing any provider.
|
|
7
|
+
|
|
8
|
+
## What to record
|
|
9
|
+
|
|
10
|
+
One **receipt per model call**, built from the numbers the provider returned:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
receipt = fs.make_usage_receipt("anthropic", "claude-sonnet-5-5",
|
|
14
|
+
input_tokens=u.input_tokens, output_tokens=u.output_tokens,
|
|
15
|
+
cache_read_tokens=u.cache_read_input_tokens or 0,
|
|
16
|
+
cost_usd=charged, cost_basis="provider") # or "price-list"
|
|
17
|
+
ledger.record(receipt) # fs.JsonlUsageLedger(data / "usage.jsonl")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
ledger.record(k.makeUsageReceipt('openrouter', model, { inputTokens, outputTokens, costUsd: usage.cost ?? null }));
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **The provider's own numbers.** Take tokens from the response's usage block, not from a local
|
|
25
|
+
estimate. The cost is `provider` when the provider reported the charge (OpenRouter `usage.cost`,
|
|
26
|
+
a billing API) and `price-list` when you computed it from a published price list.
|
|
27
|
+
- **Unknown is `None`/`null`, never `0`.** A local model, or a provider that reports no price,
|
|
28
|
+
records `cost_usd=None`. The report counts it in `unpricedCalls`, and a host shows a lower bound
|
|
29
|
+
(«≥ $x») or «unknown». A call that is really free (a template, a cached answer) records
|
|
30
|
+
`cost_usd=0.0` with `price-list`.
|
|
31
|
+
- **No content.** A receipt carries no prompt, output, caller or user id. The kits accept only the
|
|
32
|
+
fields above.
|
|
33
|
+
- If the service already keeps a ledger (a jobs table with token counts), build the receipts from
|
|
34
|
+
it and pass them to `usage_report(...)`. That avoids a second store.
|
|
35
|
+
|
|
36
|
+
## What to answer
|
|
37
|
+
|
|
38
|
+
Declare the surface in the well-known document and serve the report behind the service token:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
surfaces={..., "usage": {"path": fs.USAGE_PATH}} # "/fabric/v1/usage"
|
|
42
|
+
# GET /fabric/v1/usage, token required:
|
|
43
|
+
return send(200, ledger.report(service_id=ID, instance=INSTANCE, budget={"period": "month", "limitUsd": 100}))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`usage_report` / `usageReport` give the last 31 UTC days, oldest first, per provider and model,
|
|
47
|
+
with day totals that are the sums of the model rows (`FAC-SEM-025`). Days without calls are
|
|
48
|
+
left out.
|
|
49
|
+
|
|
50
|
+
**Budget.** Pass `budget` only if the service **enforces** that limit itself. The report states
|
|
51
|
+
it, and the host only shows it.
|
|
52
|
+
|
|
53
|
+
Call `ledger.prune()` at start (or daily) to keep the file at the reported window (LC-12).
|
|
54
|
+
|
|
55
|
+
`JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
|
|
56
|
+
next receipt never glues onto it.
|
|
57
|
+
|
|
58
|
+
## Verify
|
|
59
|
+
|
|
60
|
+
`check_service.py <id>` reads a declared report: `usage.requires-token` (401 without the token)
|
|
61
|
+
and `usage.report` (identity, USD, dates forward, null for all-unpriced rows, sums). The
|
|
62
|
+
`sample_service.py` serves one. Fabric Dashboards → **Spend** shows it within a minute of opening
|
|
63
|
+
the page.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -324,6 +324,42 @@ class Probe:
|
|
|
324
324
|
self.add("events.page", "FAIL" if bad else "PASS",
|
|
325
325
|
("bad events: " + ", ".join(bad)) if bad else "%d event(s), cursor %r" % (len(events), page["cursor"]))
|
|
326
326
|
|
|
327
|
+
def usage_rules(self) -> None:
|
|
328
|
+
"""DEC-0021: a declared usage report requires the token and adds up (FAC-SEM-025)."""
|
|
329
|
+
usage = ((self.wk or {}).get("surfaces") or {}).get("usage")
|
|
330
|
+
if not usage:
|
|
331
|
+
self.add("usage.report", "NOT_RUN", "surfaces.usage not declared (optional; a service that calls paid models should offer it)")
|
|
332
|
+
return
|
|
333
|
+
path = str(usage.get("path", ""))
|
|
334
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
335
|
+
self.add("usage.report", "FAIL", "surfaces.usage.path %r is not a path on the origin" % path)
|
|
336
|
+
return
|
|
337
|
+
try:
|
|
338
|
+
status, _, _ = self.request("GET", path)
|
|
339
|
+
self.add("usage.requires-token", "PASS" if status == 401 else "FAIL", "HTTP %d without a token" % status)
|
|
340
|
+
except OSError as exc:
|
|
341
|
+
self.add("usage.requires-token", "NOT_RUN", str(exc))
|
|
342
|
+
if not self.token:
|
|
343
|
+
self.add("usage.report", "NOT_RUN", "no readable token")
|
|
344
|
+
return
|
|
345
|
+
try:
|
|
346
|
+
status, _, body = self.request("GET", path, self.auth_headers())
|
|
347
|
+
except OSError as exc:
|
|
348
|
+
self.add("usage.report", "NOT_RUN", str(exc))
|
|
349
|
+
return
|
|
350
|
+
if status != 200:
|
|
351
|
+
self.add("usage.report", "FAIL", "HTTP %d with the token" % status)
|
|
352
|
+
return
|
|
353
|
+
try:
|
|
354
|
+
report = json.loads(body)
|
|
355
|
+
except ValueError:
|
|
356
|
+
self.add("usage.report", "FAIL", "not JSON")
|
|
357
|
+
return
|
|
358
|
+
problems = usage_problems(report, str(self.d.get("id")), str(self.d.get("instance", "default")))
|
|
359
|
+
days = report.get("days") if isinstance(report, dict) else None
|
|
360
|
+
self.add("usage.report", "FAIL" if problems else "PASS",
|
|
361
|
+
"; ".join(problems[:5]) if problems else "%d day(s), unknown cost kept null" % len(days or []))
|
|
362
|
+
|
|
327
363
|
def login_rules(self) -> None:
|
|
328
364
|
dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
|
|
329
365
|
if not dashboard or not dashboard.get("login"):
|
|
@@ -360,7 +396,7 @@ class Probe:
|
|
|
360
396
|
body["_meta"] = {"io.modelcontextprotocol/protocolVersion": fi.MCP_REVISION,
|
|
361
397
|
"io.modelcontextprotocol/clientCapabilities": {}, "traceparent": self.sent_traceparent}
|
|
362
398
|
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
|
|
363
|
-
|
|
399
|
+
**fi.mcp_request_headers(method, params)}
|
|
364
400
|
headers.update(self.auth_headers())
|
|
365
401
|
message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": body}).encode()
|
|
366
402
|
status, resp_headers, raw = self.request("POST", path, headers, message)
|
|
@@ -412,9 +448,24 @@ class Probe:
|
|
|
412
448
|
extra = sorted(set(listed) - names)
|
|
413
449
|
self.add("interop.well-known-capabilities", "FAIL" if extra else "PASS",
|
|
414
450
|
("listed but not in the manifest: " + ", ".join(extra)) if extra else "%d listed, all in the manifest" % len(listed))
|
|
415
|
-
|
|
451
|
+
own = bool(mcp_surface) and mcp_surface.get("auth") == "own"
|
|
452
|
+
if own and self.token:
|
|
453
|
+
# DEC-0024: the MCP surface has credentials of its own and must refuse the host's token.
|
|
454
|
+
try:
|
|
455
|
+
self.mcp_call("tools/list", {})
|
|
456
|
+
self.add("interop.mcp-own-auth", "FAIL", "surfaces.mcp.auth is own, yet the MCP surface accepted the descriptor's token")
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
refused = any(code in str(exc) for code in ("HTTP 401", "HTTP 403"))
|
|
459
|
+
self.add("interop.mcp-own-auth", "PASS" if refused else "NOT_RUN",
|
|
460
|
+
("the MCP surface refuses the host's token (%s), as auth: own says" % exc) if refused else str(exc))
|
|
461
|
+
except ValueError as exc:
|
|
462
|
+
self.add("interop.mcp-own-auth", "NOT_RUN", str(exc))
|
|
463
|
+
if not mcp_surface or not self.token or own:
|
|
464
|
+
reason = ("no MCP surface" if not mcp_surface
|
|
465
|
+
else "surfaces.mcp.auth is own: the MCP surface takes its callers' credentials, not the host's token (DEC-0024)" if own
|
|
466
|
+
else "no readable token")
|
|
416
467
|
for rule in ("interop.output-schema-object", "interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
|
|
417
|
-
self.add(rule, "NOT_RUN",
|
|
468
|
+
self.add(rule, "NOT_RUN", reason)
|
|
418
469
|
else:
|
|
419
470
|
try:
|
|
420
471
|
listing = self.mcp_call("tools/list", {})
|
|
@@ -621,12 +672,64 @@ class Probe:
|
|
|
621
672
|
if self.wk is not None:
|
|
622
673
|
self.network_rules()
|
|
623
674
|
self.auth_rules()
|
|
675
|
+
self.usage_rules()
|
|
624
676
|
self.login_rules()
|
|
625
677
|
self.interop_rules()
|
|
626
678
|
self.lifecycle_rules()
|
|
627
679
|
return self.results
|
|
628
680
|
|
|
629
681
|
|
|
682
|
+
def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
|
|
683
|
+
"""What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
|
|
684
|
+
FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
|
|
685
|
+
if not isinstance(report, dict):
|
|
686
|
+
return ["not an object"]
|
|
687
|
+
out: List[str] = []
|
|
688
|
+
if report.get("protocol") != fs.PROTOCOL:
|
|
689
|
+
out.append("protocol is %r" % report.get("protocol"))
|
|
690
|
+
if report.get("service") != {"id": service_id, "instance": instance}:
|
|
691
|
+
out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
|
|
692
|
+
if report.get("currency") != "USD":
|
|
693
|
+
out.append("currency is not USD")
|
|
694
|
+
days = report.get("days")
|
|
695
|
+
if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
|
|
696
|
+
return out + ["days is not a list of at most 31"]
|
|
697
|
+
fields = ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
|
|
698
|
+
|
|
699
|
+
def priced(row: Dict[str, Any], at: str) -> None:
|
|
700
|
+
calls, unpriced, cost = row.get("calls", 0), row.get("unpricedCalls", 0), row.get("costUsd")
|
|
701
|
+
if unpriced > calls:
|
|
702
|
+
out.append("%s: more unpriced calls than calls" % at)
|
|
703
|
+
if calls and unpriced == calls and cost is not None:
|
|
704
|
+
out.append("%s: every call is unpriced, so the cost must be null, not %r" % (at, cost))
|
|
705
|
+
if unpriced < calls and cost is None:
|
|
706
|
+
out.append("%s: priced calls carry a cost" % at)
|
|
707
|
+
if cost is not None and (not isinstance(cost, (int, float)) or cost < 0):
|
|
708
|
+
out.append("%s: costUsd is not a non-negative number" % at)
|
|
709
|
+
|
|
710
|
+
previous = ""
|
|
711
|
+
for i, day in enumerate(days):
|
|
712
|
+
at = "days[%d]" % i
|
|
713
|
+
date = str(day.get("date", "")) if isinstance(day, dict) else ""
|
|
714
|
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date) or date <= previous:
|
|
715
|
+
out.append("%s: dates must run forward" % at)
|
|
716
|
+
previous = date
|
|
717
|
+
priced(day, at)
|
|
718
|
+
models = day.get("byModel") if isinstance(day, dict) else None
|
|
719
|
+
if not isinstance(models, list):
|
|
720
|
+
out.append("%s: byModel missing" % at)
|
|
721
|
+
continue
|
|
722
|
+
for j, m in enumerate(models):
|
|
723
|
+
priced(m, "%s.byModel[%d]" % (at, j))
|
|
724
|
+
for f in fields:
|
|
725
|
+
if day.get(f, 0) != sum(m.get(f, 0) for m in models):
|
|
726
|
+
out.append("%s: %s is not the sum of its models" % (at, f))
|
|
727
|
+
known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)))
|
|
728
|
+
if isinstance(day.get("costUsd"), (int, float)) and abs(day["costUsd"] - known) > 1e-6:
|
|
729
|
+
out.append("%s: costUsd is not the sum of its models' known costs" % at)
|
|
730
|
+
return out
|
|
731
|
+
|
|
732
|
+
|
|
630
733
|
def same_repository(a: str, b: str) -> bool:
|
|
631
734
|
"""git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
|
|
632
735
|
def norm(u: str) -> str:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -220,3 +220,80 @@ export class JobStore {
|
|
|
220
220
|
cancel(jobId) { return this.transition(jobId, 'cancelled'); }
|
|
221
221
|
}
|
|
222
222
|
// #endregion interop-kit-node
|
|
223
|
+
|
|
224
|
+
// --- MCP 2026-07-28 standard headers ------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
export const NAMED_METHODS = { 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' };
|
|
227
|
+
export const HEADER_MISMATCH = -32020; // the code the official 2026-07-28 server SDK answers a header/body mismatch with
|
|
228
|
+
|
|
229
|
+
const SENTINEL = ['=?base64?', '?='];
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as it
|
|
233
|
+
* is when it is printable ASCII without leading or trailing whitespace, otherwise — and for a plain
|
|
234
|
+
* value that itself looks like the sentinel — `=?base64?<base64 of its UTF-8>?=`.
|
|
235
|
+
*/
|
|
236
|
+
export function encodeHeaderValue(value) {
|
|
237
|
+
const s = String(value);
|
|
238
|
+
const plain = /^[\x20-\x7e]*$/.test(s) && s === s.replace(/^ +| +$/g, '') && !(s.startsWith(SENTINEL[0]) && s.endsWith(SENTINEL[1]));
|
|
239
|
+
if (plain) return s;
|
|
240
|
+
let binary = '';
|
|
241
|
+
for (const byte of new TextEncoder().encode(s)) binary += String.fromCharCode(byte);
|
|
242
|
+
return SENTINEL[0] + btoa(binary) + SENTINEL[1];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The body value a header carries, decoding the Base64 sentinel; null for invalid characters or a
|
|
246
|
+
* sentinel that is not valid Base64 of UTF-8 (a server rejects both). */
|
|
247
|
+
export function decodeHeaderValue(value) {
|
|
248
|
+
const s = String(value);
|
|
249
|
+
if (s.startsWith(SENTINEL[0]) && s.endsWith(SENTINEL[1]) && s.length >= SENTINEL[0].length + SENTINEL[1].length) {
|
|
250
|
+
const body = s.slice(SENTINEL[0].length, -SENTINEL[1].length);
|
|
251
|
+
if (body.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(body)) return null;
|
|
252
|
+
try {
|
|
253
|
+
const bytes = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
|
|
254
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
255
|
+
} catch { return null; }
|
|
256
|
+
}
|
|
257
|
+
return /^[\x20-\x7e\t]*$/.test(s) ? s : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
|
|
262
|
+
* notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
|
|
263
|
+
* resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must carry
|
|
264
|
+
* them. Returns the problem as a sentence, or null. `headers` is a plain object (any case) or a
|
|
265
|
+
* Headers instance.
|
|
266
|
+
*/
|
|
267
|
+
export function mcpHeaderProblem(message, headers) {
|
|
268
|
+
if (!message || typeof message !== 'object' || !headers) return null;
|
|
269
|
+
const header = (name) => {
|
|
270
|
+
if (typeof headers.get === 'function') return headers.get(name) ?? null;
|
|
271
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
272
|
+
const value = key === undefined ? undefined : headers[key];
|
|
273
|
+
return value === undefined ? null : String(Array.isArray(value) ? value[0] : value);
|
|
274
|
+
};
|
|
275
|
+
const declared = header('MCP-Protocol-Version') === MCP_REVISION;
|
|
276
|
+
const { method } = message;
|
|
277
|
+
const sent = header('Mcp-Method');
|
|
278
|
+
if (sent === null && declared) return `the body names method ${method} but the required Mcp-Method header is absent`;
|
|
279
|
+
if (sent !== null && sent !== method) return `Mcp-Method ${sent} disagrees with the body's method ${method}`;
|
|
280
|
+
const field = NAMED_METHODS[method];
|
|
281
|
+
if (field) {
|
|
282
|
+
const named = header('Mcp-Name');
|
|
283
|
+
if (named === null && declared) return `the body carries params.${field} but the required Mcp-Name header is absent`;
|
|
284
|
+
if (named !== null) {
|
|
285
|
+
const decoded = decodeHeaderValue(named);
|
|
286
|
+
if (decoded === null) return 'Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value';
|
|
287
|
+
if (decoded !== String(message.params?.[field])) return `Mcp-Name disagrees with the body's params.${field}`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The standard headers a 2026-07-28 client sends with one request. */
|
|
294
|
+
export function mcpRequestHeaders(method, params = {}) {
|
|
295
|
+
const out = { 'MCP-Protocol-Version': MCP_REVISION, 'Mcp-Method': method };
|
|
296
|
+
const field = NAMED_METHODS[method];
|
|
297
|
+
if (field && params[field] !== undefined && params[field] !== null) out['Mcp-Name'] = encodeHeaderValue(params[field]);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Reference kit for the fabric-service/0.1 local service extension — Node.js 20+, no dependencies.
|
|
2
2
|
// The Node twin of fabric_service.py: same rules, same file formats, interoperable locks.
|
|
3
3
|
// Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
|
|
4
|
-
// placement — an online agent or dashboard at an https origin — DEC-0019).
|
|
4
|
+
// placement — an online agent or dashboard at an https origin — DEC-0019; the usage report DEC-0021).
|
|
5
5
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import fs from 'node:fs';
|
|
@@ -12,6 +12,19 @@ import path from 'node:path';
|
|
|
12
12
|
export const PROTOCOL = 'fabric-service/0.1';
|
|
13
13
|
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
|
|
14
14
|
export const EXIT_ALREADY_RUNNING = 75;
|
|
15
|
+
// Lifecycle contract (fabric-workspace knowledge/lifecycle.md), the same values as fabric_service.py.
|
|
16
|
+
// A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
|
|
17
|
+
export const SUPERVISOR_ENV = 'FABRIC_SERVICE_SUPERVISOR';
|
|
18
|
+
export const LOCK_WAIT_SUPERVISED_SECONDS = 300;
|
|
19
|
+
export const LOCK_BACKOFF_FIRST_SECONDS = 0.5;
|
|
20
|
+
export const LOCK_BACKOFF_MAX_SECONDS = 30;
|
|
21
|
+
// LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, hard exit 2 s later.
|
|
22
|
+
export const DRAIN_SECONDS = 8;
|
|
23
|
+
export const DRAIN_GRACE_SECONDS = 2;
|
|
24
|
+
export const EXIT_HARD_STOP = 70;
|
|
25
|
+
// LC-12: logs rotate by size, 5 x 5 MB by default.
|
|
26
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
27
|
+
export const LOG_BACKUPS = 5;
|
|
15
28
|
export const LEVELS = ['info', 'notice', 'warning', 'error'];
|
|
16
29
|
export const STATUSES = ['starting', 'ready', 'degraded', 'stopping'];
|
|
17
30
|
export const EVENTS_DEFAULT_LIMIT = 50;
|
|
@@ -151,13 +164,42 @@ function readPid(file) {
|
|
|
151
164
|
}
|
|
152
165
|
}
|
|
153
166
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
/** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
|
|
168
|
+
export const supervisedByLaunchd = () => process.env[SUPERVISOR_ENV] === 'launchd';
|
|
169
|
+
|
|
170
|
+
const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Take the lock or exit 75 with one sentence naming the holder. Started by hand, a held lock exits
|
|
174
|
+
* 75 at once. Under launchd (KeepAlive) an immediate exit is a respawn every ThrottleInterval,
|
|
175
|
+
* forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
|
|
176
|
+
* LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
|
|
177
|
+
* the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
|
|
178
|
+
*/
|
|
179
|
+
export async function holdSingleInstance(dataDir, {
|
|
180
|
+
waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
|
|
181
|
+
} = {}) {
|
|
182
|
+
const wait = waitSeconds ?? (supervisedByLaunchd() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
|
|
183
|
+
const deadline = clock() + Math.max(0, wait);
|
|
184
|
+
let delay = LOCK_BACKOFF_FIRST_SECONDS;
|
|
185
|
+
let announced = false;
|
|
186
|
+
for (;;) {
|
|
187
|
+
try {
|
|
188
|
+
return await new InstanceLock(dataDir).acquire();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (!(error instanceof AlreadyRunning)) throw error;
|
|
191
|
+
const remaining = deadline - clock();
|
|
192
|
+
if (remaining <= 0) {
|
|
193
|
+
process.stderr.write(`${error.message}\n`);
|
|
194
|
+
return exit(EXIT_ALREADY_RUNNING);
|
|
195
|
+
}
|
|
196
|
+
if (!announced) {
|
|
197
|
+
process.stderr.write(`${error.message} Waiting up to ${Math.round(remaining)} s for it to exit.\n`);
|
|
198
|
+
announced = true;
|
|
199
|
+
}
|
|
200
|
+
await sleep(Math.min(delay, remaining));
|
|
201
|
+
delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
|
|
202
|
+
}
|
|
161
203
|
}
|
|
162
204
|
}
|
|
163
205
|
|
|
@@ -346,6 +388,273 @@ export async function eventsPage(fetch, after, limit) {
|
|
|
346
388
|
return { events, cursor: events.length ? events.at(-1).id : (after ?? null) };
|
|
347
389
|
}
|
|
348
390
|
|
|
391
|
+
// --- usage report (DEC-0021) -------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
export const USAGE_PATH = '/fabric/v1/usage';
|
|
394
|
+
export const USAGE_DAYS = 31;
|
|
395
|
+
export const COST_BASES = ['provider', 'price-list', 'unknown'];
|
|
396
|
+
const PROVIDER = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
397
|
+
const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'];
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
401
|
+
* `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
|
|
402
|
+
*/
|
|
403
|
+
export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at } = {}) {
|
|
404
|
+
if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
|
|
405
|
+
model = String(model ?? '').trim();
|
|
406
|
+
if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
|
|
407
|
+
const counts = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
|
|
408
|
+
for (const [name, value] of Object.entries(counts)) if (!Number.isInteger(value) || value < 0) throw new ServiceError(`${name} must be a non-negative integer.`);
|
|
409
|
+
if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
|
|
410
|
+
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
411
|
+
if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
|
|
412
|
+
return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
416
|
+
const emptyRow = (extra = {}) => ({ ...extra, calls: 0, unpricedCalls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0 });
|
|
417
|
+
function closeRow(row) {
|
|
418
|
+
const { cost, ...rest } = row;
|
|
419
|
+
return { ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The service-usage.schema.json answer: the last 31 UTC days, oldest first, per provider and
|
|
424
|
+
* model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
|
|
425
|
+
* are the sums of the model rows (FAC-SEM-025).
|
|
426
|
+
*/
|
|
427
|
+
export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget } = {}) {
|
|
428
|
+
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000), last = utcDate(now);
|
|
429
|
+
const days = new Map();
|
|
430
|
+
for (const r of receipts) {
|
|
431
|
+
const date = String(r.at ?? '').slice(0, 10);
|
|
432
|
+
if (date < first || date > last) continue;
|
|
433
|
+
const rows = days.get(date) ?? new Map();
|
|
434
|
+
days.set(date, rows);
|
|
435
|
+
const k = `${r.provider}\u0000${r.model}`;
|
|
436
|
+
const row = rows.get(k) ?? emptyRow({ provider: r.provider, model: r.model, bases: new Set() });
|
|
437
|
+
rows.set(k, row);
|
|
438
|
+
row.calls += 1;
|
|
439
|
+
for (const c of COUNTS) row[c] += Number(r[c] ?? 0);
|
|
440
|
+
if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd;
|
|
441
|
+
row.bases.add(r.costBasis ?? 'unknown');
|
|
442
|
+
}
|
|
443
|
+
const outDays = [...days.keys()].sort().map((date) => {
|
|
444
|
+
const models = [...days.get(date).values()]
|
|
445
|
+
.map(({ bases, ...row }) => closeRow({ ...row, costBasis: bases.size === 1 ? [...bases][0] : 'mixed' }))
|
|
446
|
+
.sort((a, b) => (a.provider + a.model < b.provider + b.model ? -1 : 1));
|
|
447
|
+
const day = emptyRow({ date });
|
|
448
|
+
for (const m of models) {
|
|
449
|
+
for (const c of ['calls', 'unpricedCalls', ...COUNTS]) day[c] += m[c];
|
|
450
|
+
day.cost += m.costUsd ?? 0;
|
|
451
|
+
}
|
|
452
|
+
return { ...closeRow(day), byModel: models };
|
|
453
|
+
});
|
|
454
|
+
const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
|
|
455
|
+
if (budget !== undefined) {
|
|
456
|
+
if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
|
|
457
|
+
const prefix = budget.period === 'day' ? last : last.slice(0, 7);
|
|
458
|
+
const window = outDays.filter((d) => d.date.startsWith(prefix));
|
|
459
|
+
const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
|
|
460
|
+
report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6 };
|
|
461
|
+
}
|
|
462
|
+
return report;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Usage receipts for services that keep none yet: append-only JSON lines, 0600, pruned to the window (LC-12).
|
|
466
|
+
* One writer process (the service holds its instance lock); every method is synchronous, so calls never interleave. */
|
|
467
|
+
export class JsonlUsageLedger {
|
|
468
|
+
constructor(file) { this.file = file; }
|
|
469
|
+
|
|
470
|
+
record(receipt) {
|
|
471
|
+
ensurePrivateDir(path.dirname(this.file));
|
|
472
|
+
const fd = fs.openSync(this.file, 'a+', 0o600);
|
|
473
|
+
try {
|
|
474
|
+
const { size } = fs.fstatSync(fd);
|
|
475
|
+
const last = Buffer.alloc(1);
|
|
476
|
+
// A killed writer left a torn line: never glue a receipt to it.
|
|
477
|
+
const lead = size && fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a ? '\n' : '';
|
|
478
|
+
fs.writeSync(fd, lead + JSON.stringify(receipt) + '\n');
|
|
479
|
+
} finally {
|
|
480
|
+
fs.closeSync(fd);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
receipts() {
|
|
485
|
+
if (!fs.existsSync(this.file)) return [];
|
|
486
|
+
const out = [];
|
|
487
|
+
for (const line of fs.readFileSync(this.file, 'utf8').split('\n')) {
|
|
488
|
+
if (!line) continue;
|
|
489
|
+
try { out.push(JSON.parse(line)); } catch { /* a torn last line from a killed writer is skipped */ }
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
prune(now = Date.now()) {
|
|
495
|
+
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000);
|
|
496
|
+
const all = this.receipts();
|
|
497
|
+
const kept = all.filter((r) => String(r.at ?? '').slice(0, 10) >= first);
|
|
498
|
+
if (kept.length !== all.length) atomicWrite(this.file, kept.map((r) => JSON.stringify(r) + '\n').join(''));
|
|
499
|
+
return all.length - kept.length;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
report(options) { return usageReport(this.receipts(), options); }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// --- logs (LC-12) ------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a 0700
|
|
509
|
+
* directory. `service.jsonl` -> `.1` ... `.<backups>`; the oldest falls off. Never pass a token,
|
|
510
|
+
* a cookie, a login code or a request body that may carry one.
|
|
511
|
+
*/
|
|
512
|
+
export class RotatingLog {
|
|
513
|
+
constructor(file, { maxBytes = LOG_MAX_BYTES, backups = LOG_BACKUPS } = {}) {
|
|
514
|
+
if (maxBytes < 256 || backups < 1) throw new ServiceError('A rotating log needs maxBytes >= 256 and at least one backup.');
|
|
515
|
+
this.file = file;
|
|
516
|
+
this.maxBytes = maxBytes;
|
|
517
|
+
this.backups = backups;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
line(record) {
|
|
521
|
+
let data = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
522
|
+
if (data.length <= this.maxBytes) return data;
|
|
523
|
+
const cut = { ...record, truncated: true };
|
|
524
|
+
let message = String(record.message ?? '');
|
|
525
|
+
while (message) {
|
|
526
|
+
message = message.slice(0, Math.floor(message.length / 2));
|
|
527
|
+
cut.message = message;
|
|
528
|
+
data = Buffer.from(`${JSON.stringify(cut)}\n`);
|
|
529
|
+
if (data.length <= this.maxBytes) return data;
|
|
530
|
+
}
|
|
531
|
+
return Buffer.from(`${JSON.stringify({ at: record.at, level: record.level, message: '', truncated: true })}\n`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
write(level, message, fields = {}) {
|
|
535
|
+
const data = this.line({ at: nowIso(), level: String(level), message: String(message), ...fields });
|
|
536
|
+
ensurePrivateDir(path.dirname(this.file));
|
|
537
|
+
let size = 0;
|
|
538
|
+
try { size = fs.statSync(this.file).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
539
|
+
if (size && size + data.length > this.maxBytes) this.rotate();
|
|
540
|
+
const fd = fs.openSync(this.file, 'a', 0o600);
|
|
541
|
+
try { fs.writeSync(fd, data); } finally { fs.closeSync(fd); }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
rotate() {
|
|
545
|
+
for (let i = this.backups - 1; i > 0; i -= 1) {
|
|
546
|
+
if (fs.existsSync(`${this.file}.${i}`)) fs.renameSync(`${this.file}.${i}`, `${this.file}.${i + 1}`);
|
|
547
|
+
}
|
|
548
|
+
fs.renameSync(this.file, `${this.file}.1`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Call once at start: a launchd stdout file over `maxBytes` is copied to `<file>.1` and truncated
|
|
554
|
+
* in place (launchd holds it open for append, so a rename would leave it writing to the old name).
|
|
555
|
+
*/
|
|
556
|
+
export function capStdoutLog(file, maxBytes = LOG_MAX_BYTES) {
|
|
557
|
+
let size;
|
|
558
|
+
try { size = fs.statSync(file).size; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
559
|
+
if (size <= maxBytes) return false;
|
|
560
|
+
fs.copyFileSync(file, `${file}.1`);
|
|
561
|
+
fs.chmodSync(`${file}.1`, 0o600);
|
|
562
|
+
fs.truncateSync(file, 0);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// --- shutdown (LC-01) ----------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
export class Stopping extends ServiceError {}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* SIGTERM/SIGINT: stop taking new work, drain in-flight work until `deadline` seconds, then hand
|
|
572
|
+
* over with `onStop(drained)`. A hand-over that never settles is cut by a hard exit
|
|
573
|
+
* (EXIT_HARD_STOP) `grace` seconds later.
|
|
574
|
+
*
|
|
575
|
+
* const drain = new Drain().install((drained) => server.close(() => process.exit(0)),
|
|
576
|
+
* { onStopping: () => log.append('service.stopping', 'info', 'Stopping.') });
|
|
577
|
+
* await drain.work(async () => { ... }); // rejects with Stopping once a stop has begun
|
|
578
|
+
*/
|
|
579
|
+
export class Drain {
|
|
580
|
+
constructor({ deadline = DRAIN_SECONDS, grace = DRAIN_GRACE_SECONDS } = {}) {
|
|
581
|
+
this.deadline = deadline;
|
|
582
|
+
this.grace = grace;
|
|
583
|
+
this.inflight = 0;
|
|
584
|
+
this.stopping = false;
|
|
585
|
+
this.waiters = new Set();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async work(fn) {
|
|
589
|
+
if (this.stopping) throw new Stopping('The service is stopping and takes no new work.');
|
|
590
|
+
this.inflight += 1;
|
|
591
|
+
try {
|
|
592
|
+
return await fn();
|
|
593
|
+
} finally {
|
|
594
|
+
this.inflight -= 1;
|
|
595
|
+
if (this.inflight === 0) for (const wake of this.waiters) wake();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Refuse new work from now on; false when a stop had already begun. */
|
|
600
|
+
requestStop() {
|
|
601
|
+
if (this.stopping) return false;
|
|
602
|
+
this.stopping = true;
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Resolves true when in-flight work finished inside `timeoutSeconds`, false otherwise. */
|
|
607
|
+
wait(timeoutSeconds) {
|
|
608
|
+
if (this.inflight === 0) return Promise.resolve(true);
|
|
609
|
+
return new Promise((resolve) => {
|
|
610
|
+
const wake = () => { clearTimeout(timer); this.waiters.delete(wake); resolve(true); };
|
|
611
|
+
const timer = setTimeout(() => { this.waiters.delete(wake); resolve(false); }, timeoutSeconds * 1000);
|
|
612
|
+
this.waiters.add(wake);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
install(onStop, { onStopping, signals = ['SIGTERM', 'SIGINT'] } = {}) {
|
|
617
|
+
const handler = () => {
|
|
618
|
+
if (!this.requestStop()) return; // a second signal changes nothing: the hard exit holds the deadline
|
|
619
|
+
setTimeout(() => process.exit(EXIT_HARD_STOP), (this.deadline + this.grace) * 1000).unref();
|
|
620
|
+
(async () => {
|
|
621
|
+
try { await onStopping?.(); } catch (error) { process.stderr.write(`${error?.stack ?? error}\n`); }
|
|
622
|
+
await onStop(await this.wait(this.deadline));
|
|
623
|
+
})();
|
|
624
|
+
};
|
|
625
|
+
for (const signal of signals) process.on(signal, handler);
|
|
626
|
+
return this;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// --- releases (LC-11, LC-15) -----------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Keep the release the job runs and the one before it; remove older release directories, newest
|
|
634
|
+
* first by modification time. `current` (a release or a symlink to one) is never removed, even
|
|
635
|
+
* after a rollback. Symlinks and files are left alone. Returns the removed paths.
|
|
636
|
+
*/
|
|
637
|
+
export function pruneReleases(releasesDir, { keep = 2, current } = {}) {
|
|
638
|
+
if (keep < 2) throw new ServiceError('keep at least the current release and the one before it (keep >= 2).');
|
|
639
|
+
if (!fs.existsSync(releasesDir)) return [];
|
|
640
|
+
const releases = fs.readdirSync(releasesDir, { withFileTypes: true })
|
|
641
|
+
.filter((e) => e.isDirectory() && !e.isSymbolicLink())
|
|
642
|
+
.map((e) => path.join(releasesDir, e.name))
|
|
643
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
644
|
+
const keepers = [];
|
|
645
|
+
if (current) {
|
|
646
|
+
const resolved = fs.realpathSync(current);
|
|
647
|
+
keepers.push(...releases.filter((p) => fs.realpathSync(p) === resolved));
|
|
648
|
+
}
|
|
649
|
+
for (const release of releases) {
|
|
650
|
+
if (keepers.length >= keep) break;
|
|
651
|
+
if (!keepers.includes(release)) keepers.push(release);
|
|
652
|
+
}
|
|
653
|
+
const removed = releases.filter((p) => !keepers.includes(p));
|
|
654
|
+
for (const release of removed) fs.rmSync(release, { recursive: true, force: true });
|
|
655
|
+
return removed;
|
|
656
|
+
}
|
|
657
|
+
|
|
349
658
|
// --- operator login -------------------------------------------------------------------
|
|
350
659
|
|
|
351
660
|
export class LoginCodes {
|