@passioncode-ai/fabric-agent-adapter 0.26.0 → 0.28.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 +90 -0
- package/README.md +6 -2
- 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 +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- 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 +60 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +211 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +294 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// The Fabric hub's door, as a service agent reaches it (fabric-hub/0.1; Fabric ADR-0115 §1, ADR-0149 §4; FAA-12b).
|
|
2
|
+
// The twin of fabric_hub.py, same rules and the same answers; Node built-ins only.
|
|
3
|
+
//
|
|
4
|
+
// 1. Find the hub, every time: locateHub() re-reads hub.json beside Fabric's registry (macOS
|
|
5
|
+
// ~/Library/Application Support/ai.passioncode.fabric, Windows %LOCALAPPDATA%\passioncode-fabric, Linux
|
|
6
|
+
// $XDG_DATA_HOME/passioncode-fabric) the way Fabric's own locateHub does: a small regular file, never a link or a
|
|
7
|
+
// FIFO; protocol fabric-hub/0.1; origin exactly http://127.0.0.1:<port>; MCP at /mcp; a door token file inside the
|
|
8
|
+
// registry root; a pid that is alive. The port does not prove who holds it, so a binding credential is sent only
|
|
9
|
+
// after this check passes, done again before each send.
|
|
10
|
+
// 2. Ask once, keep the credential in a file: requestBoardAccess() asks for the board.respond grant through the door;
|
|
11
|
+
// pollAccess() writes the credential, shown once, to a 0600 file and answers without it. Tokens live in files and
|
|
12
|
+
// headers only — never argv, a URL, a log line or an error text.
|
|
13
|
+
// 3. Join the board: joinBoard() calls fabric.board.join {projectId} and answers a BoardGrant (a one-session bearer
|
|
14
|
+
// that fabric-board.mjs's BoardSession spends with one MCP initialize). Without a grant: HubError not_authorized
|
|
15
|
+
// whose data.ask holds the fabric.access.request arguments.
|
|
16
|
+
//
|
|
17
|
+
// Fabric down: every call throws HubError with retryable true; Backoff gives the bounded, jittered wait (2 s doubling to
|
|
18
|
+
// 5 minutes), so nothing spins. fabric-hub/0.1 publishes no hub key; a later hub.json naming hubKey {kid, x} is
|
|
19
|
+
// checked with fabric-estate.mjs's hubKeyCertified when the caller passes an estate record, refused with DEC-0060's
|
|
20
|
+
// own detail.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
|
|
27
|
+
import { hubKeyCertified } from './fabric-estate.mjs';
|
|
28
|
+
|
|
29
|
+
export const HUB_PROTOCOL = 'fabric-hub/0.1';
|
|
30
|
+
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
31
|
+
export const READ_CAP = 64 * 1024;
|
|
32
|
+
const LOOPBACK_ORIGIN = /^http:\/\/127\.0\.0\.1:(\d{4,5})$/;
|
|
33
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
34
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
35
|
+
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
|
|
36
|
+
|
|
37
|
+
/** A typed refusal or fault: code, one-sentence message, retryable, data (never a secret). */
|
|
38
|
+
export class HubError extends Error {
|
|
39
|
+
constructor(code, message, retryable = false, data = {}) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'HubError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.retryable = retryable;
|
|
44
|
+
this.data = data ?? {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fabric's registry root, where hub.json lives (Fabric agentRegistry.ts#registryDirs). */
|
|
49
|
+
export function fabricRoot(platform = process.platform, env = process.env) {
|
|
50
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
51
|
+
if (platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric');
|
|
52
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'passioncode-fabric');
|
|
53
|
+
return path.join(env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether a process with this id runs as this user (Fabric hub.ts#pidAlive): EPERM is another user's process. */
|
|
57
|
+
export function pidAlive(pid) {
|
|
58
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
59
|
+
try {
|
|
60
|
+
process.kill(pid, 0);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readSmall(file) {
|
|
68
|
+
const info = fs.lstatSync(file);
|
|
69
|
+
if (!info.isFile() || info.size > READ_CAP) return null;
|
|
70
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.fstatSync(fd).isFile()) return null;
|
|
73
|
+
const buf = Buffer.alloc(READ_CAP + 1);
|
|
74
|
+
let got = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const n = fs.readSync(fd, buf, got, buf.length - got, null);
|
|
77
|
+
if (n === 0) break;
|
|
78
|
+
got += n;
|
|
79
|
+
if (got > READ_CAP) return null;
|
|
80
|
+
}
|
|
81
|
+
return buf.subarray(0, got).toString('utf8');
|
|
82
|
+
} finally {
|
|
83
|
+
fs.closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* hub.json read the way an agent must, every call. Answers {origin, mcpUrl, doorTokenFile, pid, startedAt, features} or
|
|
89
|
+
* throws HubError (not-running, retryable; not-a-hub). Reads no token. `estate` is {record, applied}: with it, a hubKey
|
|
90
|
+
* the document names is checked by hubKeyCertified (DEC-0060); fabric-hub/0.1 names none.
|
|
91
|
+
*/
|
|
92
|
+
export function locateHub(root = fabricRoot(), { alive = pidAlive, estate, now } = {}) {
|
|
93
|
+
const hubFile = path.join(root, 'hub.json');
|
|
94
|
+
const notHub = (reason) => new HubError('not-a-hub', `Fabric's hub.json ${reason}.`);
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = readSmall(hubFile);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e.code === 'ENOENT') throw new HubError('not-running', 'Fabric is not running: there is no hub.json.', true);
|
|
100
|
+
throw new HubError('not-a-hub', `Fabric's hub.json could not be read (${e.code ?? 'error'}).`, true);
|
|
101
|
+
}
|
|
102
|
+
if (text === null) throw notHub('is not a small regular file');
|
|
103
|
+
let doc;
|
|
104
|
+
try {
|
|
105
|
+
doc = JSON.parse(text);
|
|
106
|
+
} catch {
|
|
107
|
+
throw notHub('is not JSON');
|
|
108
|
+
}
|
|
109
|
+
if (!doc || typeof doc !== 'object' || doc.protocol !== HUB_PROTOCOL) throw notHub(`does not speak ${HUB_PROTOCOL}`);
|
|
110
|
+
const origin = typeof doc.origin === 'string' ? doc.origin : '';
|
|
111
|
+
const m = LOOPBACK_ORIGIN.exec(origin);
|
|
112
|
+
if (!m || Number(m[1]) < 1024 || Number(m[1]) > 65535) throw notHub('names an origin that is not the loopback literal');
|
|
113
|
+
if (doc.mcp !== '/mcp') throw notHub('names another MCP path');
|
|
114
|
+
if (!Number.isInteger(doc.pid) || doc.pid <= 0) throw notHub('names no process');
|
|
115
|
+
const tokenFile = typeof doc.doorTokenFile === 'string' ? doc.doorTokenFile : '';
|
|
116
|
+
let sameRoot = false;
|
|
117
|
+
try {
|
|
118
|
+
sameRoot = path.isAbsolute(tokenFile) && fs.realpathSync(path.dirname(tokenFile)) === fs.realpathSync(root);
|
|
119
|
+
} catch {
|
|
120
|
+
sameRoot = false;
|
|
121
|
+
}
|
|
122
|
+
if (!sameRoot) throw notHub('names a door token outside the registry root');
|
|
123
|
+
if (!alive(doc.pid)) throw new HubError('not-running', `Fabric is not running: hub.json names process ${doc.pid}, which is not running.`, true);
|
|
124
|
+
const features = Array.isArray(doc.features) ? doc.features.filter((f) => typeof f === 'string') : [];
|
|
125
|
+
if (estate && doc.hubKey && typeof doc.hubKey === 'object') {
|
|
126
|
+
const verdict = hubKeyCertified(estate.record, doc.hubKey.kid, doc.hubKey.x, now ?? Date.now() / 1000, estate.applied ?? undefined);
|
|
127
|
+
if (!verdict.certified) throw new HubError('hub-key-refused', `The estate record does not certify the hub's key (${verdict.detail}).`, false, { fault: verdict.fault, detail: verdict.detail });
|
|
128
|
+
}
|
|
129
|
+
return { origin, mcpUrl: `${origin}/mcp`, doorTokenFile: tokenFile, pid: doc.pid, startedAt: doc.startedAt, features };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The door token hub.json names: a 0600 file of this user, never a link, 43 base64url characters. */
|
|
133
|
+
export function readDoorToken(location) {
|
|
134
|
+
let token;
|
|
135
|
+
try {
|
|
136
|
+
token = readToken(location.doorTokenFile);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e.code === 'ENOENT') throw new HubError('not-running', 'Fabric is starting or stopping: its door token is not there.', true);
|
|
139
|
+
throw new HubError('not-a-hub', `The door token file is not one Fabric writes (${e.message}).`);
|
|
140
|
+
}
|
|
141
|
+
if (!TOKEN.test(token)) throw new HubError('not-a-hub', 'The door token is not one Fabric writes.');
|
|
142
|
+
return token;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A binding credential from its 0600 file: 43 base64url characters. */
|
|
146
|
+
export function readCredential(file) {
|
|
147
|
+
let token;
|
|
148
|
+
try {
|
|
149
|
+
token = readToken(file);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (e.code === 'ENOENT') throw new HubError('credential', `No binding credential at ${file}: ask for the grant first (requestBoardAccess).`);
|
|
152
|
+
throw new HubError('credential', e.message);
|
|
153
|
+
}
|
|
154
|
+
if (!TOKEN.test(token)) throw new HubError('credential', `The file ${file} does not hold a Fabric binding credential.`);
|
|
155
|
+
return token;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One MCP request to <origin>/mcp with a bearer in the header; only http://127.0.0.1:<port>, no proxy. Answers
|
|
160
|
+
* {status, headers, body} (body parsed JSON or null; an SSE answer read to its first data: line). A refused connection
|
|
161
|
+
* is HubError hub-unavailable (retryable).
|
|
162
|
+
*/
|
|
163
|
+
export function postMcp(origin, body, bearer, { session, timeout = 15000, method = 'POST', protocolVersion } = {}) {
|
|
164
|
+
const m = LOOPBACK_ORIGIN.exec(origin);
|
|
165
|
+
if (!m) return Promise.reject(new HubError('not-a-hub', 'Only the hub\'s loopback origin is used.'));
|
|
166
|
+
const payload = method === 'DELETE' ? null : Buffer.from(JSON.stringify(body));
|
|
167
|
+
const headers = { authorization: `Bearer ${bearer}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream', host: `127.0.0.1:${m[1]}` };
|
|
168
|
+
if (session) headers['mcp-session-id'] = session;
|
|
169
|
+
if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion;
|
|
170
|
+
if (payload) headers['content-length'] = String(payload.length);
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const req = http.request({ host: '127.0.0.1', port: Number(m[1]), path: '/mcp', method, headers, timeout, agent: false }, (res) => {
|
|
173
|
+
const chunks = [];
|
|
174
|
+
let size = 0;
|
|
175
|
+
res.on('data', (c) => {
|
|
176
|
+
size += c.length;
|
|
177
|
+
if (size <= 4 * 1024 * 1024) chunks.push(c);
|
|
178
|
+
});
|
|
179
|
+
res.on('end', () => {
|
|
180
|
+
let text = Buffer.concat(chunks).toString('utf8');
|
|
181
|
+
if (String(res.headers['content-type'] ?? '').includes('text/event-stream')) {
|
|
182
|
+
text = (text.split(/\r?\n/).find((l) => l.startsWith('data:')) ?? '').slice(5).trim();
|
|
183
|
+
}
|
|
184
|
+
let parsed = null;
|
|
185
|
+
try {
|
|
186
|
+
parsed = text.trim() ? JSON.parse(text) : null;
|
|
187
|
+
} catch {
|
|
188
|
+
parsed = null;
|
|
189
|
+
}
|
|
190
|
+
resolve({ status: res.statusCode, headers: res.headers, body: parsed });
|
|
191
|
+
});
|
|
192
|
+
res.on('error', () => reject(new HubError('hub-unavailable', 'Fabric\'s hub did not answer (the answer broke off).', true)));
|
|
193
|
+
});
|
|
194
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
195
|
+
req.on('error', (e) => reject(new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
|
|
196
|
+
if (payload) req.write(payload);
|
|
197
|
+
req.end();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A tools/call answer as an object: structuredContent, else the first text content parsed. */
|
|
202
|
+
export function toolAnswer({ status, body }, tool) {
|
|
203
|
+
if (status === 401) throw new HubError('credential', `The hub refused the credential for ${tool}.`);
|
|
204
|
+
if (status === 429 || status >= 500) throw new HubError('hub-unavailable', `Fabric's hub could not take ${tool} just now (HTTP ${status}).`, true);
|
|
205
|
+
if (status !== 200 || !body || typeof body !== 'object') throw new HubError('protocol', `Fabric's hub answered ${tool} with HTTP ${status} and no MCP answer.`, true);
|
|
206
|
+
if (body.error) throw new HubError('protocol', `Fabric refused ${tool}: ${String(body.error?.message ?? 'an MCP error').slice(0, 200)}`);
|
|
207
|
+
const result = body.result;
|
|
208
|
+
if (!result || typeof result !== 'object') throw new HubError('protocol', `Fabric's answer to ${tool} has no result.`);
|
|
209
|
+
let value = result.structuredContent;
|
|
210
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
211
|
+
const text = Array.isArray(result.content) && result.content[0] && typeof result.content[0].text === 'string' ? result.content[0].text : null;
|
|
212
|
+
try {
|
|
213
|
+
value = text === null ? null : JSON.parse(text);
|
|
214
|
+
} catch {
|
|
215
|
+
value = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HubError('protocol', `Fabric's answer to ${tool} is not a JSON object.`);
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What fabric.board.join handed out. The bearer is a private field, kept off JSON and inspection. */
|
|
223
|
+
export class BoardGrant {
|
|
224
|
+
#bearer;
|
|
225
|
+
constructor(bearer, sessionId, projectId, capabilities, origin, pid) {
|
|
226
|
+
this.#bearer = bearer;
|
|
227
|
+
this.sessionId = sessionId;
|
|
228
|
+
this.projectId = projectId;
|
|
229
|
+
this.capabilities = [...capabilities];
|
|
230
|
+
this.origin = origin;
|
|
231
|
+
this.pid = pid;
|
|
232
|
+
}
|
|
233
|
+
bearer() {
|
|
234
|
+
return this.#bearer;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The grant's resource, board:<project>/<capability> (ADR-0149 §2). */
|
|
239
|
+
export const boardResource = (projectId, capability) => `board:${projectId}/${capability}`;
|
|
240
|
+
|
|
241
|
+
/** A Project id as Fabric's board compares it: a uuid lower-cased; other ids are opaque, compared as written (DEC-0077). */
|
|
242
|
+
export function normalProject(projectId) {
|
|
243
|
+
if (typeof projectId !== 'string' || !UUID.test(projectId)) throw new HubError('protocol', 'fabric.board.join takes a Project id that is a uuid.');
|
|
244
|
+
return projectId.toLowerCase();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The door client. Every call re-reads hub.json first. `alive` and `post` are seams for tests. */
|
|
248
|
+
export class HubClient {
|
|
249
|
+
constructor(root = fabricRoot(), { alive = pidAlive, timeout = 15000, estate, post = postMcp } = {}) {
|
|
250
|
+
Object.assign(this, { root, alive, timeout, estate, post });
|
|
251
|
+
this.rpc = 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
locate() {
|
|
255
|
+
return locateHub(this.root, { alive: this.alive, estate: this.estate });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #call(bearerOf, tool, args) {
|
|
259
|
+
const location = this.locate(); // the pid check, right before the credential leaves this process
|
|
260
|
+
const bearer = bearerOf(location);
|
|
261
|
+
this.rpc += 1;
|
|
262
|
+
const answer = await this.post(location.origin, { jsonrpc: '2.0', id: this.rpc, method: 'tools/call', params: { name: tool, arguments: args } }, bearer, { timeout: this.timeout });
|
|
263
|
+
const value = toolAnswer(answer, tool);
|
|
264
|
+
if (value.error && typeof value.error === 'object' && typeof value.error.code === 'string') {
|
|
265
|
+
const data = value.error.data && typeof value.error.data === 'object' ? value.error.data : {};
|
|
266
|
+
throw new HubError(value.error.code, String(value.error.message ?? 'refused').slice(0, 300), value.error.code === 'hub-unavailable', data);
|
|
267
|
+
}
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
callDoor(tool, args) {
|
|
272
|
+
return this.#call(readDoorToken, tool, args);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
callBinding(tool, args, credentialFile) {
|
|
276
|
+
return this.#call(() => readCredential(credentialFile), tool, args);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Ask the operator, once, to let agentId answer capabilities for projectId (fabric.access.request, board.respond). */
|
|
280
|
+
async requestBoardAccess(agentId, projectId, capabilities, reason) {
|
|
281
|
+
const project = normalProject(projectId);
|
|
282
|
+
const caps = [...(capabilities ?? [])];
|
|
283
|
+
if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c))) throw new HubError('protocol', 'Name at least one capability, each [a-z][a-z0-9._-]{1,127}.');
|
|
284
|
+
return this.callDoor('fabric.access.request', { agentId, callee: 'fabric', capabilities: ['board.respond'], resources: caps.map((c) => boardResource(project, c)), reason });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Read the request through the door; on Allow the credential is written to credentialFile (0600) and left out. */
|
|
288
|
+
async pollAccess(requestId, pollSecret, credentialFile) {
|
|
289
|
+
const answer = await this.callDoor('fabric.access.status', { requestId, pollSecret });
|
|
290
|
+
const { credential, ...rest } = answer;
|
|
291
|
+
if (typeof credential === 'string') {
|
|
292
|
+
if (!TOKEN.test(credential)) throw new HubError('protocol', 'Fabric handed out a credential this kit does not recognise; nothing was saved.');
|
|
293
|
+
ensurePrivateDir(path.dirname(credentialFile));
|
|
294
|
+
atomicWrite(credentialFile, credential, 0o600);
|
|
295
|
+
rest.credentialSaved = true;
|
|
296
|
+
}
|
|
297
|
+
return rest;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** fabric.board.join {projectId}: a board session for one Project. */
|
|
301
|
+
async joinBoard(projectId, credentialFile) {
|
|
302
|
+
const project = normalProject(projectId);
|
|
303
|
+
let answer;
|
|
304
|
+
try {
|
|
305
|
+
answer = await this.callBinding('fabric.board.join', { projectId: project }, credentialFile);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (e instanceof HubError && (e.code === 'not_authorized' || e.code === 'not-authorized')) {
|
|
308
|
+
throw new HubError('not_authorized', 'The operator has not let this agent answer requests for that Project: ask with fabric.access.request (callee fabric, capability board.respond, resource board:<project>/<capability>).',
|
|
309
|
+
false, { ask: { callee: 'fabric', capabilities: ['board.respond'], resources: [boardResource(project, '<capability>')] } });
|
|
310
|
+
}
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
const s = answer.session;
|
|
314
|
+
if (!s || typeof s !== 'object' || typeof s.bearer !== 'string' || typeof s.sessionId !== 'string') throw new HubError('protocol', 'fabric.board.join answered no board session.');
|
|
315
|
+
if (s.projectId !== project) throw new HubError('protocol', 'fabric.board.join answered a session for another Project.');
|
|
316
|
+
const caps = Array.isArray(s.capabilities) ? s.capabilities.filter((c) => typeof c === 'string') : [];
|
|
317
|
+
const location = this.locate();
|
|
318
|
+
return new BoardGrant(s.bearer, s.sessionId, project, caps, location.origin, location.pid);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Bounded exponential backoff with jitter: first seconds doubling to cap, ±jitter, never under floor. */
|
|
323
|
+
export class Backoff {
|
|
324
|
+
constructor({ first = 2, cap = 300, jitter = 0.2, floor = 1, rand = Math.random } = {}) {
|
|
325
|
+
Object.assign(this, { first, cap, jitter, floor, rand });
|
|
326
|
+
this.attempt = 0;
|
|
327
|
+
}
|
|
328
|
+
next() {
|
|
329
|
+
const base = Math.min(this.cap, this.first * 2 ** Math.min(this.attempt, 32));
|
|
330
|
+
this.attempt += 1;
|
|
331
|
+
const wait = base * (1 + this.jitter * (2 * this.rand() - 1));
|
|
332
|
+
return Math.max(this.floor, Math.min(this.cap, wait));
|
|
333
|
+
}
|
|
334
|
+
reset() {
|
|
335
|
+
this.attempt = 0;
|
|
336
|
+
}
|
|
337
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'node:fs';
|
|
|
9
9
|
import net from 'node:net';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
+
import { normalIdentifier } from './fabric-identifiers.mjs';
|
|
12
13
|
import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
13
14
|
|
|
14
15
|
export const PROTOCOL = 'fabric-service/0.1';
|
|
@@ -1016,7 +1017,11 @@ const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTok
|
|
|
1016
1017
|
* One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
1017
1018
|
* `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
|
|
1018
1019
|
*/
|
|
1019
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* One model call. `projectId` (a URI, kept in its normal form, DEC-0076) and `taskRunId` (the service's own run id) attribute
|
|
1022
|
+
* it to the project the service ran it for (DEC-0065); they never name a caller, session or person.
|
|
1023
|
+
*/
|
|
1024
|
+
export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route, projectId, taskRunId } = {}) {
|
|
1020
1025
|
if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
|
|
1021
1026
|
model = String(model ?? '').trim();
|
|
1022
1027
|
if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
|
|
@@ -1026,7 +1031,34 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
|
|
|
1026
1031
|
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
1027
1032
|
if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
|
|
1028
1033
|
if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
|
|
1029
|
-
|
|
1034
|
+
if (taskRunId !== undefined && projectId === undefined) throw new ServiceError('a task run belongs to a project: give projectId with taskRunId.');
|
|
1035
|
+
if (projectId !== undefined && !(typeof projectId === 'string' && /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(projectId))) throw new ServiceError('projectId is the URI of the project the service ran the call for.');
|
|
1036
|
+
if (taskRunId !== undefined && !(typeof taskRunId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(taskRunId))) throw new ServiceError("taskRunId is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.");
|
|
1037
|
+
return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }),
|
|
1038
|
+
...(projectId === undefined ? {} : { projectId: normalIdentifier(projectId) }), ...(taskRunId === undefined ? {} : { taskRunId }) };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** DEC-0065's `byProject`: the window's calls by project (normal form, DEC-0076), each task run once within it. */
|
|
1042
|
+
function byProject(receipts, first, last) {
|
|
1043
|
+
const projects = new Map();
|
|
1044
|
+
const add = (row, r) => { row.calls += 1; if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd; };
|
|
1045
|
+
for (const r of receipts) {
|
|
1046
|
+
const date = String(r.at ?? '').slice(0, 10);
|
|
1047
|
+
if (date < first || date > last || !r.projectId) continue;
|
|
1048
|
+
if (!projects.has(r.projectId)) projects.set(r.projectId, { projectId: r.projectId, calls: 0, unpricedCalls: 0, cost: 0, runs: new Map() });
|
|
1049
|
+
const row = projects.get(r.projectId);
|
|
1050
|
+
add(row, r);
|
|
1051
|
+
if (r.taskRunId) {
|
|
1052
|
+
if (!row.runs.has(r.taskRunId)) row.runs.set(r.taskRunId, { taskRunId: r.taskRunId, calls: 0, unpricedCalls: 0, cost: 0 });
|
|
1053
|
+
add(row.runs.get(r.taskRunId), r);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
|
|
1057
|
+
return [...projects.keys()].sort().map((key) => {
|
|
1058
|
+
const { runs, ...row } = projects.get(key);
|
|
1059
|
+
const taskRuns = [...runs.keys()].sort().map((id) => close(runs.get(id)));
|
|
1060
|
+
return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
|
|
1061
|
+
});
|
|
1030
1062
|
}
|
|
1031
1063
|
|
|
1032
1064
|
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
@@ -1041,6 +1073,181 @@ function closeRow(row) {
|
|
|
1041
1073
|
* model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
|
|
1042
1074
|
* are the sums of the model rows (FAC-SEM-025).
|
|
1043
1075
|
*/
|
|
1076
|
+
// --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` --------------------------------
|
|
1077
|
+
|
|
1078
|
+
const usageCount = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : 0);
|
|
1079
|
+
const usageCost = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
1080
|
+
const exceeds = (a, b) => a - b > 0.000001 + 1e-9 * Math.max(Math.abs(a), Math.abs(b));
|
|
1081
|
+
const usd = (v) => String(Math.round(v * 1e6) / 1e6);
|
|
1082
|
+
const usageList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'object' && x !== null && !Array.isArray(x)) : []);
|
|
1083
|
+
const RFC3339_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
1084
|
+
function strictTime(value) {
|
|
1085
|
+
const m = typeof value === 'string' ? RFC3339_TIME.exec(value) : null;
|
|
1086
|
+
if (!m) return NaN;
|
|
1087
|
+
const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
|
|
1088
|
+
const date = new Date(0);
|
|
1089
|
+
date.setUTCFullYear(y, mo - 1, d);
|
|
1090
|
+
if (date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d || h > 23 || mi > 59 || s > 59) return NaN;
|
|
1091
|
+
return Date.parse(value);
|
|
1092
|
+
}
|
|
1093
|
+
function utcDateMs(value) {
|
|
1094
|
+
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
|
|
1095
|
+
if (!m) return NaN;
|
|
1096
|
+
const [y, mo, d] = m.slice(1).map(Number);
|
|
1097
|
+
const ms = Date.UTC(y, mo - 1, d);
|
|
1098
|
+
const date = new Date(ms);
|
|
1099
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d ? ms : NaN;
|
|
1100
|
+
}
|
|
1101
|
+
function pricedRow(row, at) {
|
|
1102
|
+
const calls = usageCount(row.calls), unpriced = usageCount(row.unpricedCalls);
|
|
1103
|
+
const flag = (p, message) => [{ code: 'FAC-SEM-025', instancePath: p, message }];
|
|
1104
|
+
if (unpriced > calls) return flag(`${at}/unpricedCalls`, 'more unpriced calls than calls');
|
|
1105
|
+
if (calls > 0 && unpriced === calls && row.costUsd !== null) return flag(`${at}/costUsd`, 'every call is unpriced, so the cost is unknown (null), not a number');
|
|
1106
|
+
if (unpriced < calls && row.costUsd === null) return flag(`${at}/costUsd`, 'priced calls carry a cost; null is only for a row with no priced call');
|
|
1107
|
+
return [];
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic, the window (every
|
|
1112
|
+
* date exists, not after `generatedAt`'s UTC date, at most 30 days before it) and the project attribution (each project
|
|
1113
|
+
* once by its normal form, each task run once; calls, priced calls and known costs bounded). Never throws.
|
|
1114
|
+
*/
|
|
1115
|
+
export function usageFindings(report) {
|
|
1116
|
+
const value = typeof report === 'object' && report !== null && !Array.isArray(report) ? report : {};
|
|
1117
|
+
const findings = [];
|
|
1118
|
+
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-025', instancePath, message });
|
|
1119
|
+
const days = usageList(value.days);
|
|
1120
|
+
let previous = '';
|
|
1121
|
+
days.forEach((day, i) => {
|
|
1122
|
+
const at = `/days/${i}`;
|
|
1123
|
+
const date = String(day.date ?? '');
|
|
1124
|
+
if (date <= previous) flag(`${at}/date`, `days must run forward without a repeat (${date} after ${previous})`);
|
|
1125
|
+
previous = date;
|
|
1126
|
+
findings.push(...pricedRow(day, at));
|
|
1127
|
+
const models = usageList(day.byModel);
|
|
1128
|
+
models.forEach((m, j) => findings.push(...pricedRow(m, `${at}/byModel/${j}`)));
|
|
1129
|
+
if (!models.length) {
|
|
1130
|
+
const spent = ['calls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some((f) => Number(day[f] ?? 0) > 0) || Number(day.costUsd ?? 0) > 0;
|
|
1131
|
+
if (spent) flag(`${at}/byModel`, 'a day with calls, tokens or cost names the models they belong to');
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
for (const field of ['calls', 'unpricedCalls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
|
|
1135
|
+
const sum = models.reduce((n, m) => n + Number(m[field] ?? 0), 0);
|
|
1136
|
+
if (Number(day[field] ?? 0) !== sum) flag(`${at}/${field}`, `the day's ${field} is not the sum of its models (${String(day[field] ?? 0)} ≠ ${sum})`);
|
|
1137
|
+
}
|
|
1138
|
+
const known = models.map((m) => (typeof m.costUsd === 'number' ? m.costUsd : null)).filter((x) => x !== null).reduce((n, x) => n + x, 0);
|
|
1139
|
+
if (typeof day.costUsd === 'number' && Math.abs(day.costUsd - known) > 0.000001 + 1e-9 * Math.max(Math.abs(day.costUsd), Math.abs(known))) flag(`${at}/costUsd`, "the day's cost is not the sum of its models' known costs");
|
|
1140
|
+
});
|
|
1141
|
+
if (days.length) {
|
|
1142
|
+
const generated = strictTime(value.generatedAt);
|
|
1143
|
+
if (Number.isNaN(generated)) flag('/generatedAt', 'generatedAt is an RFC 3339 date-time with an offset, so the window can be read');
|
|
1144
|
+
else {
|
|
1145
|
+
const today = Math.floor(generated / 86_400_000) * 86_400_000;
|
|
1146
|
+
const oldest = today - 30 * 86_400_000;
|
|
1147
|
+
days.forEach((day, i) => {
|
|
1148
|
+
const date = utcDateMs(day.date);
|
|
1149
|
+
if (Number.isNaN(date)) flag(`/days/${i}/date`, "a day's date exists in its month");
|
|
1150
|
+
else if (date > today) flag(`/days/${i}/date`, 'a day is not after the UTC date of generatedAt');
|
|
1151
|
+
else if (date < oldest) flag(`/days/${i}/date`, 'a day is within the last 31 UTC days of generatedAt');
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (Array.isArray(value.byProject)) {
|
|
1156
|
+
const seen = new Map();
|
|
1157
|
+
let projectCalls = 0, projectCost = 0;
|
|
1158
|
+
value.byProject.forEach((project, p) => {
|
|
1159
|
+
if (typeof project !== 'object' || project === null || Array.isArray(project)) return;
|
|
1160
|
+
const at = `/byProject/${p}`;
|
|
1161
|
+
const normal = normalIdentifier(project.projectId);
|
|
1162
|
+
if (normal !== undefined) {
|
|
1163
|
+
if (seen.has(normal)) flag(`${at}/projectId`, `each project is one row: ${String(project.projectId)} names the project ${seen.get(normal)} already names`);
|
|
1164
|
+
else seen.set(normal, String(project.projectId));
|
|
1165
|
+
}
|
|
1166
|
+
projectCalls += usageCount(project.calls);
|
|
1167
|
+
projectCost += usageCost(project.costUsd);
|
|
1168
|
+
findings.push(...pricedRow(project, at));
|
|
1169
|
+
const runs = new Set();
|
|
1170
|
+
let runCalls = 0, runCost = 0, runPriced = 0;
|
|
1171
|
+
usageList(project.taskRuns).forEach((run, r) => {
|
|
1172
|
+
if (typeof run.taskRunId === 'string') {
|
|
1173
|
+
if (runs.has(run.taskRunId)) flag(`${at}/taskRuns/${r}/taskRunId`, 'each task run is one row within its project');
|
|
1174
|
+
runs.add(run.taskRunId);
|
|
1175
|
+
}
|
|
1176
|
+
runCalls += usageCount(run.calls);
|
|
1177
|
+
runCost += usageCost(run.costUsd);
|
|
1178
|
+
runPriced += Math.max(0, usageCount(run.calls) - usageCount(run.unpricedCalls));
|
|
1179
|
+
findings.push(...pricedRow(run, `${at}/taskRuns/${r}`));
|
|
1180
|
+
});
|
|
1181
|
+
const projectPriced = Math.max(0, usageCount(project.calls) - usageCount(project.unpricedCalls));
|
|
1182
|
+
if (runCalls > usageCount(project.calls)) flag(`${at}/taskRuns`, `a project's task runs hold more calls than the project (${runCalls} > ${usageCount(project.calls)})`);
|
|
1183
|
+
else if (runPriced > projectPriced) flag(`${at}/taskRuns`, `a project's task runs hold more priced calls than the project (${runPriced} > ${projectPriced})`);
|
|
1184
|
+
else if (usageCost(project.costUsd) === project.costUsd && exceeds(runCost, usageCost(project.costUsd))) flag(`${at}/taskRuns`, `a project's task runs cost more than the project (${usd(runCost)} > ${usd(usageCost(project.costUsd))})`);
|
|
1185
|
+
});
|
|
1186
|
+
const dayCalls = days.reduce((n, d) => n + usageCount(d.calls), 0);
|
|
1187
|
+
if (projectCalls > dayCalls) flag('/byProject', `the projects hold more calls than the days (${projectCalls} > ${dayCalls})`);
|
|
1188
|
+
const dayCost = days.reduce((n, d) => n + usageCost(d.costUsd), 0);
|
|
1189
|
+
if (exceeds(projectCost, dayCost)) flag('/byProject', `the projects cost more than the days (${usd(projectCost)} > ${usd(dayCost)})`);
|
|
1190
|
+
}
|
|
1191
|
+
return findings;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default ------------------------------
|
|
1195
|
+
|
|
1196
|
+
export const OPENROUTER_CATEGORIES = Object.freeze(['cli-agent', 'ide-extension', 'cloud-agent', 'programming-app', 'native-app-builder',
|
|
1197
|
+
'creative-writing', 'video-gen', 'image-gen', 'audio-gen', 'writing-assistant', 'general-chat', 'personal-agent', 'legal', 'roleplay', 'game']);
|
|
1198
|
+
const ATTR_PRODUCT = /^[a-z][a-z0-9-]{0,62}$/;
|
|
1199
|
+
const ATTR_TITLE = /^[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?$/;
|
|
1200
|
+
const ATTRIBUTION_HEADER_NAMES = new Set(['http-referer', 'referer', 'x-openrouter-title', 'x-title', 'x-openrouter-categories']);
|
|
1201
|
+
|
|
1202
|
+
/** FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories. */
|
|
1203
|
+
export function attributionSettingFindings(value) {
|
|
1204
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return [{ code: 'FAC-SEM-098', instancePath: '', message: 'an attribution setting is an object' }];
|
|
1205
|
+
const findings = [];
|
|
1206
|
+
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-098', instancePath, message });
|
|
1207
|
+
if (typeof value.product !== 'string' || !ATTR_PRODUCT.test(value.product)) flag('/product', 'the product is a lower-case slug, the last segment of its product page');
|
|
1208
|
+
if (typeof value.title !== 'string' || !ATTR_TITLE.test(value.title)) flag('/title', "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end");
|
|
1209
|
+
if (value.categories !== undefined) {
|
|
1210
|
+
if (!Array.isArray(value.categories)) flag('/categories', 'categories are a list');
|
|
1211
|
+
else {
|
|
1212
|
+
if (value.categories.length > 2) flag('/categories', 'at most 2 categories per request');
|
|
1213
|
+
const seen = new Set();
|
|
1214
|
+
value.categories.forEach((c, i) => {
|
|
1215
|
+
if (typeof c !== 'string' || !OPENROUTER_CATEGORIES.includes(c)) flag(`/categories/${i}`, 'a category is one OpenRouter recognizes; it drops others without an error');
|
|
1216
|
+
else if (seen.has(c)) flag(`/categories/${i}`, 'each category once');
|
|
1217
|
+
else seen.add(c);
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return findings;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function isOpenRouter(url) {
|
|
1225
|
+
try {
|
|
1226
|
+
const u = new URL(url);
|
|
1227
|
+
return u.protocol === 'https:' && u.hostname === 'openrouter.ai' && u.port === '' && u.username === '' && u.password === '';
|
|
1228
|
+
} catch {
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
|
|
1235
|
+
* `no-setting` (off by default), `invalid-setting`, `not-openrouter`, `forwarded`, `user-set`. Only `HTTP-Referer`,
|
|
1236
|
+
* `X-OpenRouter-Title` and `X-OpenRouter-Categories`; it never reads, moves or returns a key.
|
|
1237
|
+
*/
|
|
1238
|
+
export function attributionHeaders(setting, given) {
|
|
1239
|
+
const request = typeof given === 'object' && given !== null ? given : {};
|
|
1240
|
+
if (setting === undefined || setting === null) return { headers: {}, none: 'no-setting' };
|
|
1241
|
+
if (attributionSettingFindings(setting).length) return { headers: {}, none: 'invalid-setting' };
|
|
1242
|
+
if (typeof request.url !== 'string' || !isOpenRouter(request.url)) return { headers: {}, none: 'not-openrouter' };
|
|
1243
|
+
if (request.originated !== true) return { headers: {}, none: 'forwarded' };
|
|
1244
|
+
const present = typeof request.headers === 'object' && request.headers !== null ? Object.keys(request.headers).map((n) => n.toLowerCase()) : [];
|
|
1245
|
+
if (present.some((n) => ATTRIBUTION_HEADER_NAMES.has(n))) return { headers: {}, none: 'user-set' };
|
|
1246
|
+
const headers = { 'HTTP-Referer': `https://passioncode.ai/${setting.product}/`, 'X-OpenRouter-Title': setting.title };
|
|
1247
|
+
if (setting.categories?.length) headers['X-OpenRouter-Categories'] = setting.categories.join(',');
|
|
1248
|
+
return { headers };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1044
1251
|
/**
|
|
1045
1252
|
* `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
|
|
1046
1253
|
* calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
|
|
@@ -1077,6 +1284,8 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
|
|
|
1077
1284
|
return { ...closeRow(day), byModel: models };
|
|
1078
1285
|
});
|
|
1079
1286
|
const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
|
|
1287
|
+
const projects = byProject(receipts, first, last);
|
|
1288
|
+
if (projects.length) report.byProject = projects;
|
|
1080
1289
|
if (budget !== undefined) {
|
|
1081
1290
|
if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
|
|
1082
1291
|
report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
|