@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- 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 +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- 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 +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock and the supervisor marker.
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_lock.py. Node 20+, no dependency.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { SUPERVISOR_ENV, ServiceError } from './fabric-base.mjs';
|
|
8
|
+
import { atomicWrite, ensurePrivateDir } from './fabric-paths.mjs';
|
|
9
|
+
|
|
10
|
+
export const EXIT_ALREADY_RUNNING = 75;
|
|
11
|
+
|
|
12
|
+
export const LOCK_WAIT_SUPERVISED_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
export const LOCK_BACKOFF_FIRST_SECONDS = 0.5;
|
|
15
|
+
|
|
16
|
+
export const LOCK_BACKOFF_MAX_SECONDS = 30;
|
|
17
|
+
|
|
18
|
+
const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
|
|
19
|
+
|
|
20
|
+
export class AlreadyRunning extends ServiceError {
|
|
21
|
+
constructor(holderPid, lockPath) {
|
|
22
|
+
// FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
|
|
23
|
+
super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'an unknown process'} holds ${lockPath}).`);
|
|
24
|
+
this.holderPid = holderPid;
|
|
25
|
+
this.lockPath = lockPath;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class InstanceLock {
|
|
30
|
+
constructor(dataDir) {
|
|
31
|
+
this.path = path.join(dataDir, 'service.lock');
|
|
32
|
+
this.fd = null;
|
|
33
|
+
this.server = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async acquire() {
|
|
37
|
+
ensurePrivateDir(path.dirname(this.path));
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
|
|
40
|
+
try {
|
|
41
|
+
this.fd = fs.openSync(this.path, flags, 0o600);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
fs.ftruncateSync(this.fd, 0);
|
|
47
|
+
fs.writeSync(this.fd, String(process.pid));
|
|
48
|
+
fs.fsyncSync(this.fd);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
// Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
|
|
52
|
+
// with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
|
|
53
|
+
const name = lockName(this.path);
|
|
54
|
+
if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
|
|
55
|
+
this.server = net.createServer();
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
// Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
|
|
58
|
+
const held = (code) => code === 'EADDRINUSE' || (process.platform === 'win32' && code === 'EBUSY');
|
|
59
|
+
this.server.once('error', (error) => reject(held(error.code) ? new AlreadyRunning(readPid(this.path), this.path) : error));
|
|
60
|
+
this.server.listen(name, resolve);
|
|
61
|
+
});
|
|
62
|
+
this.server.unref();
|
|
63
|
+
atomicWrite(this.path, String(process.pid));
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
release() {
|
|
68
|
+
if (this.fd !== null) fs.closeSync(this.fd);
|
|
69
|
+
if (this.server) this.server.close();
|
|
70
|
+
this.fd = null;
|
|
71
|
+
this.server = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
|
|
76
|
+
export function lockName(lockPath, platform = process.platform) {
|
|
77
|
+
const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
|
78
|
+
if (platform === 'linux') return `\0fabric-service-${digest(path.posix.resolve(lockPath))}`;
|
|
79
|
+
// Windows paths compare without case: C:\Users and c:\users are one lock.
|
|
80
|
+
if (platform === 'win32') return `\\\\.\\pipe\\fabric-service-${digest(path.win32.resolve(lockPath).toLowerCase())}`;
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readPid(file) {
|
|
85
|
+
try {
|
|
86
|
+
const text = fs.readFileSync(file, 'utf8').trim();
|
|
87
|
+
return /^\d+$/.test(text) ? Number(text) : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node. */
|
|
94
|
+
export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node']);
|
|
95
|
+
|
|
96
|
+
/** The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action). */
|
|
97
|
+
export const SUPERVISOR_ARG = '--fabric-supervisor';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The marker values an argument vector carries after the executable and before `--`. A bare `--fabric-supervisor`
|
|
101
|
+
* followed by nothing, by `--` or by another `--…` flag is a marker with an empty value and consumes nothing.
|
|
102
|
+
*/
|
|
103
|
+
export function markerArguments(argv) {
|
|
104
|
+
const values = [];
|
|
105
|
+
if (!Array.isArray(argv)) return values;
|
|
106
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
107
|
+
const item = argv[index];
|
|
108
|
+
if (item === '--') break;
|
|
109
|
+
if (item === SUPERVISOR_ARG) {
|
|
110
|
+
const next = argv[index + 1];
|
|
111
|
+
if (typeof next === 'string' && !next.startsWith('--')) {
|
|
112
|
+
values.push(next);
|
|
113
|
+
index += 1;
|
|
114
|
+
} else {
|
|
115
|
+
values.push('');
|
|
116
|
+
}
|
|
117
|
+
} else if (typeof item === 'string' && item.startsWith(`${SUPERVISOR_ARG}=`)) {
|
|
118
|
+
values.push(item.slice(SUPERVISOR_ARG.length + 1));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return values;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How this copy was started (DEC-0057), from `FABRIC_SERVICE_SUPERVISOR=<manager>` and `--fabric-supervisor <manager>` /
|
|
126
|
+
* `--fabric-supervisor=<manager>` (`env` and `argv` default to this process's; `argv[0]` is the executable or script):
|
|
127
|
+
* `{kind: 'supervised', manager, forms}` when every marker present names the same manager of SUPERVISORS;
|
|
128
|
+
* `{kind: 'conflict', values, forms}` when they disagree (an empty or bare argument beside a variable disagrees with it):
|
|
129
|
+
* the copy exits 75 at once, before it takes the lock; `{kind: 'manual', forms, unreadable?}` with no marker (only an
|
|
130
|
+
* empty variable counts as none) or one that names no manager. Never inferred from the parent process. The contract's
|
|
131
|
+
* `supervisorMarker`.
|
|
132
|
+
*/
|
|
133
|
+
export function supervisorMarker({ env = process.env, argv = process.argv.slice(1) } = {}) {
|
|
134
|
+
const variable = env?.[SUPERVISOR_ENV];
|
|
135
|
+
const fromEnv = typeof variable === 'string' && variable !== '' ? [variable] : [];
|
|
136
|
+
const fromArgs = markerArguments(argv);
|
|
137
|
+
const forms = [...(fromEnv.length ? ['environment'] : []), ...(fromArgs.length ? ['argument'] : [])];
|
|
138
|
+
const values = [...new Set([...fromEnv, ...fromArgs])];
|
|
139
|
+
if (values.length > 1) return { kind: 'conflict', values, forms };
|
|
140
|
+
if (values.length === 0) return { kind: 'manual', forms };
|
|
141
|
+
return SUPERVISORS.includes(values[0]) ? { kind: 'supervised', manager: values[0], forms } : { kind: 'manual', unreadable: values[0], forms };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** How long a copy started so may wait for the lock, in seconds: 300 when supervised, else not at all. */
|
|
145
|
+
export const lockWaitBudgetSeconds = (marker) => (marker?.kind === 'supervised' ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
|
|
146
|
+
|
|
147
|
+
/** The one sentence a copy whose markers disagree exits with, naming the markers present. */
|
|
148
|
+
export function markerConflictMessage(marker) {
|
|
149
|
+
const shown = marker.values.map((v) => JSON.stringify(v)).join(', ');
|
|
150
|
+
return marker.forms.length === 2
|
|
151
|
+
? `${SUPERVISOR_ENV} and ${SUPERVISOR_ARG} disagree (${shown}); not starting unsupervised.`
|
|
152
|
+
: `${SUPERVISOR_ARG} is given with different values (${shown}); not starting unsupervised.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True when a supervisor that restarts on exit started this process and said so with the marker. */
|
|
156
|
+
export const supervised = (start) => supervisorMarker(start).kind === 'supervised';
|
|
157
|
+
|
|
158
|
+
/** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
|
|
159
|
+
export const supervisedByLaunchd = (start) => supervisorMarker(start).manager === 'launchd';
|
|
160
|
+
|
|
161
|
+
const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078). Markers that
|
|
165
|
+
* disagree exit 75 before the lock. Started by hand, a held lock exits 75 at once. Under a
|
|
166
|
+
* supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
|
|
167
|
+
* forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
|
|
168
|
+
* LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
|
|
169
|
+
* the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
|
|
170
|
+
*/
|
|
171
|
+
export async function holdSingleInstance(dataDir, {
|
|
172
|
+
waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
|
|
173
|
+
} = {}) {
|
|
174
|
+
// DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
|
|
175
|
+
const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
|
|
176
|
+
if (marker.kind === 'conflict') {
|
|
177
|
+
process.stderr.write(`${markerConflictMessage(marker)}\n`);
|
|
178
|
+
return exit(EXIT_ALREADY_RUNNING);
|
|
179
|
+
}
|
|
180
|
+
const wait = waitSeconds ?? lockWaitBudgetSeconds(marker);
|
|
181
|
+
const deadline = clock() + Math.max(0, wait);
|
|
182
|
+
let delay = LOCK_BACKOFF_FIRST_SECONDS;
|
|
183
|
+
let announced = false;
|
|
184
|
+
for (;;) {
|
|
185
|
+
try {
|
|
186
|
+
return await new InstanceLock(dataDir).acquire();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!(error instanceof AlreadyRunning)) throw error;
|
|
189
|
+
const remaining = deadline - clock();
|
|
190
|
+
if (remaining <= 0) {
|
|
191
|
+
process.stderr.write(`${error.message}\n`);
|
|
192
|
+
return exit(EXIT_ALREADY_RUNNING);
|
|
193
|
+
}
|
|
194
|
+
if (!announced) {
|
|
195
|
+
process.stderr.write(`${error.message} Waiting up to ${Math.round(remaining)} s for it to exit.\n`);
|
|
196
|
+
announced = true;
|
|
197
|
+
}
|
|
198
|
+
await sleep(Math.min(delay, remaining));
|
|
199
|
+
delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Logs and stopping (LC-01, LC-12): a rotating log, a capped stdout log, the stop signal and the drain.
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_logs.py. Node 20+, no dependency.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DRAIN_SECONDS, ServiceError, nowIso } from './fabric-base.mjs';
|
|
6
|
+
import { ensurePrivateDir } from './fabric-paths.mjs';
|
|
7
|
+
|
|
8
|
+
export const DRAIN_GRACE_SECONDS = 2;
|
|
9
|
+
|
|
10
|
+
export const EXIT_HARD_STOP = 70;
|
|
11
|
+
|
|
12
|
+
// LC-12: logs rotate by size, 5 x 5 MB by default.
|
|
13
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export const LOG_BACKUPS = 5;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a 0700
|
|
19
|
+
* directory. `service.jsonl` -> `.1` ... `.<backups>`; the oldest falls off. Never pass a token,
|
|
20
|
+
* a cookie, a login code or a request body that may carry one.
|
|
21
|
+
*/
|
|
22
|
+
export class RotatingLog {
|
|
23
|
+
constructor(file, { maxBytes = LOG_MAX_BYTES, backups = LOG_BACKUPS } = {}) {
|
|
24
|
+
if (maxBytes < 256 || backups < 1) throw new ServiceError('A rotating log needs maxBytes >= 256 and at least one backup.');
|
|
25
|
+
this.file = file;
|
|
26
|
+
this.maxBytes = maxBytes;
|
|
27
|
+
this.backups = backups;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
line(record) {
|
|
31
|
+
let data = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
32
|
+
if (data.length <= this.maxBytes) return data;
|
|
33
|
+
const cut = { ...record, truncated: true };
|
|
34
|
+
let message = String(record.message ?? '');
|
|
35
|
+
while (message) {
|
|
36
|
+
message = message.slice(0, Math.floor(message.length / 2));
|
|
37
|
+
cut.message = message;
|
|
38
|
+
data = Buffer.from(`${JSON.stringify(cut)}\n`);
|
|
39
|
+
if (data.length <= this.maxBytes) return data;
|
|
40
|
+
}
|
|
41
|
+
return Buffer.from(`${JSON.stringify({ at: record.at, level: record.level, message: '', truncated: true })}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
write(level, message, fields = {}) {
|
|
45
|
+
const data = this.line({ at: nowIso(), level: String(level), message: String(message), ...fields });
|
|
46
|
+
ensurePrivateDir(path.dirname(this.file));
|
|
47
|
+
let size = 0;
|
|
48
|
+
try { size = fs.statSync(this.file).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
49
|
+
if (size && size + data.length > this.maxBytes) this.rotate();
|
|
50
|
+
const fd = fs.openSync(this.file, 'a', 0o600);
|
|
51
|
+
try { fs.writeSync(fd, data); } finally { fs.closeSync(fd); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
rotate() {
|
|
55
|
+
for (let i = this.backups - 1; i > 0; i -= 1) {
|
|
56
|
+
if (fs.existsSync(`${this.file}.${i}`)) fs.renameSync(`${this.file}.${i}`, `${this.file}.${i + 1}`);
|
|
57
|
+
}
|
|
58
|
+
fs.renameSync(this.file, `${this.file}.1`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Call once at start: a launchd stdout file over `maxBytes` is copied to `<file>.1` and truncated
|
|
64
|
+
* in place (launchd holds it open for append, so a rename would leave it writing to the old name).
|
|
65
|
+
*/
|
|
66
|
+
export function capStdoutLog(file, maxBytes = LOG_MAX_BYTES) {
|
|
67
|
+
let size;
|
|
68
|
+
try { size = fs.statSync(file).size; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
69
|
+
if (size <= maxBytes) return false;
|
|
70
|
+
fs.copyFileSync(file, `${file}.1`);
|
|
71
|
+
fs.chmodSync(`${file}.1`, 0o600);
|
|
72
|
+
fs.truncateSync(file, 0);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class Stopping extends ServiceError {}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SIGTERM/SIGINT: stop taking new work, drain in-flight work until `deadline` seconds, then hand
|
|
80
|
+
* over with `onStop(drained)`. A hand-over that never settles is cut by a hard exit
|
|
81
|
+
* (EXIT_HARD_STOP) `grace` seconds later.
|
|
82
|
+
*
|
|
83
|
+
* const drain = new Drain().install((drained) => server.close(() => process.exit(0)),
|
|
84
|
+
* { onStopping: () => log.append('service.stopping', 'info', 'Stopping.') });
|
|
85
|
+
* await drain.work(async () => { ... }); // rejects with Stopping once a stop has begun
|
|
86
|
+
*/
|
|
87
|
+
export class Drain {
|
|
88
|
+
constructor({ deadline = DRAIN_SECONDS, grace = DRAIN_GRACE_SECONDS } = {}) {
|
|
89
|
+
this.deadline = deadline;
|
|
90
|
+
this.grace = grace;
|
|
91
|
+
this.inflight = 0;
|
|
92
|
+
this.stopping = false;
|
|
93
|
+
this.waiters = new Set();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async work(fn) {
|
|
97
|
+
if (this.stopping) throw new Stopping('The service is stopping and takes no new work.');
|
|
98
|
+
this.inflight += 1;
|
|
99
|
+
try {
|
|
100
|
+
return await fn();
|
|
101
|
+
} finally {
|
|
102
|
+
this.inflight -= 1;
|
|
103
|
+
if (this.inflight === 0) for (const wake of this.waiters) wake();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Refuse new work from now on; false when a stop had already begun. */
|
|
108
|
+
requestStop() {
|
|
109
|
+
if (this.stopping) return false;
|
|
110
|
+
this.stopping = true;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolves true when in-flight work finished inside `timeoutSeconds`, false otherwise. */
|
|
115
|
+
wait(timeoutSeconds) {
|
|
116
|
+
if (this.inflight === 0) return Promise.resolve(true);
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const wake = () => { clearTimeout(timer); this.waiters.delete(wake); resolve(true); };
|
|
119
|
+
const timer = setTimeout(() => { this.waiters.delete(wake); resolve(false); }, timeoutSeconds * 1000);
|
|
120
|
+
this.waiters.add(wake);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
install(onStop, { onStopping, signals = ['SIGTERM', 'SIGINT'] } = {}) {
|
|
125
|
+
const handler = () => {
|
|
126
|
+
if (!this.requestStop()) return; // a second signal changes nothing: the hard exit holds the deadline
|
|
127
|
+
setTimeout(() => process.exit(EXIT_HARD_STOP), (this.deadline + this.grace) * 1000).unref();
|
|
128
|
+
(async () => {
|
|
129
|
+
try { await onStopping?.(); } catch (error) { process.stderr.write(`${error?.stack ?? error}\n`); }
|
|
130
|
+
await onStop(await this.wait(this.deadline));
|
|
131
|
+
})();
|
|
132
|
+
};
|
|
133
|
+
for (const signal of signals) process.on(signal, handler);
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// OpenRouter chat completions for a service: its own key, a spend ceiling checked before every attempt, a usage receipt
|
|
2
|
+
// for every answered call, and a fallback model of another vendor. The twin of fabric_openrouter.py: the same rules, the
|
|
3
|
+
// same cases (test/fixtures/kit-openrouter-cases.json); the rules are written out in that file's docstring.
|
|
4
|
+
//
|
|
5
|
+
// const client = new Client({ ceilingUsd: 2, ledger: new JsonlUsageLedger(file), prices: pricesFromCatalog(catalog) });
|
|
6
|
+
// const answer = await client.chat([{ role: 'user', content: '...' }],
|
|
7
|
+
// { model: 'deepseek/deepseek-v4.1-flash', fallback: 'qwen/qwen3.8-flash', maxTokens: 800 });
|
|
8
|
+
// answer.content, answer.receipt.costUsd
|
|
9
|
+
//
|
|
10
|
+
// The key is read from the environment variable `keyEnv` names when a call is made, sent only in the Authorization
|
|
11
|
+
// header, never in an error. Before every attempt the window's spend (ledger receipts of the UTC day or month, plus
|
|
12
|
+
// calls in flight) plus this attempt's worst case (one token per two UTF-8 bytes of the messages as JSON, maxTokens of
|
|
13
|
+
// output, at the model's price) must stay within the ceiling. A 200 records one receipt; stop or tool_calls with
|
|
14
|
+
// content is an answer; error is retried on the model; length, content_filter and empty go to the fallback; 408, 429,
|
|
15
|
+
// 5xx and connection errors are retried within one deadline; 401, 402 and 403 stop at once. Attribution is off unless a
|
|
16
|
+
// setting is given (FAC-SEM-098). Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
|
|
17
|
+
|
|
18
|
+
import * as http from './fabric-http.mjs';
|
|
19
|
+
import { isObject } from './fabric-jsvalue.mjs';
|
|
20
|
+
import * as time from './fabric-time.mjs';
|
|
21
|
+
import { attributionHeaders, makeUsageReceipt } from './fabric-usage.mjs';
|
|
22
|
+
|
|
23
|
+
export const BASE_URL = 'https://openrouter.ai/api/v1';
|
|
24
|
+
export const ANSWERED = Object.freeze(['stop', 'tool_calls']);
|
|
25
|
+
const STOP_AT_ONCE = { 401: 'auth', 402: 'payment', 403: 'auth' };
|
|
26
|
+
const RESERVED_PARAMS = ['model', 'messages', 'max_tokens', 'max_completion_tokens', 'maxTokens', 'stream', 'usage'];
|
|
27
|
+
// Params that change what a call is billed for (other models, plugins, web search): the ceiling could not bound them.
|
|
28
|
+
const UNBOUNDED_PARAMS = ['models', 'route', 'plugins', 'web_search_options', 'n'];
|
|
29
|
+
|
|
30
|
+
export class OpenRouterError extends Error {
|
|
31
|
+
constructor(message, { kind, attempts = [] } = {}) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'OpenRouterError';
|
|
34
|
+
this.kind = kind; // config, unpriced, ceiling, auth, payment, failed
|
|
35
|
+
this.attempts = [...attempts];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const vendor = (model) => (String(model).includes('/') ? String(model).split('/', 1)[0] : '');
|
|
40
|
+
|
|
41
|
+
const DECIMAL = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/;
|
|
42
|
+
|
|
43
|
+
// A catalog price (a plain decimal string such as "0.000001", or a number) or null: blank, hex, a list, negative or
|
|
44
|
+
// not finite are null, exactly as fabric_openrouter.py reads them.
|
|
45
|
+
function price(value) {
|
|
46
|
+
let n;
|
|
47
|
+
if (typeof value === 'string') n = DECIMAL.test(value) ? Number(value) : Number.NaN;
|
|
48
|
+
else if (typeof value === 'number') n = value;
|
|
49
|
+
else return null;
|
|
50
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const count = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null); // as fabric_openrouter.py: 0 to 2**53 - 1
|
|
54
|
+
|
|
55
|
+
// Every message's content is text: a string, null, or parts that are all {type: 'text'}. An image, audio or file part is
|
|
56
|
+
// billed in ways a byte count cannot bound, so the client does not send it.
|
|
57
|
+
function textOnly(messages) {
|
|
58
|
+
return messages.every((m) => isObject(m) && (m.content === null || m.content === undefined || typeof m.content === 'string'
|
|
59
|
+
|| (Array.isArray(m.content) && m.content.every((p) => isObject(p) && p.type === 'text'))));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// {model: {prompt, completion, request}} in USD per token (per request) from GET /api/v1/models; a model without a
|
|
63
|
+
// readable prompt or completion price is left out, so the client refuses it rather than guessing.
|
|
64
|
+
export function pricesFromCatalog(catalog) {
|
|
65
|
+
const out = {};
|
|
66
|
+
const rows = isObject(catalog) && Array.isArray(catalog.data) ? catalog.data : [];
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
if (!isObject(row) || typeof row.id !== 'string') continue;
|
|
69
|
+
const pricing = isObject(row.pricing) ? row.pricing : {};
|
|
70
|
+
const prompt = price(pricing.prompt);
|
|
71
|
+
const completion = price(pricing.completion);
|
|
72
|
+
if (prompt === null || completion === null) continue;
|
|
73
|
+
out[row.id] = { prompt, completion, request: price(pricing.request) ?? 0 };
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One token per two UTF-8 bytes of what is sent as compact JSON: the messages and every param, all billed as input.
|
|
79
|
+
export const promptTokensBound = (value) => Math.ceil(http.jsonBytes(value).length / 2);
|
|
80
|
+
|
|
81
|
+
function checkAttribution(projectId, taskRunId) {
|
|
82
|
+
try {
|
|
83
|
+
makeUsageReceipt('openrouter', 'check', { inputTokens: 0, outputTokens: 0, projectId, taskRunId });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new OpenRouterError(err.message, { kind: 'config' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class Client {
|
|
90
|
+
constructor({ ceilingUsd, ledger, prices, keyEnv = 'OPENROUTER_API_KEY', window = 'day', baseUrl = BASE_URL,
|
|
91
|
+
attribution = null, projectId, unmeasuredCallUsd = null, deadlineS = 120, retries = 1, env = process.env,
|
|
92
|
+
transport, sleep, clock = () => performance.now() / 1000, now = () => Date.now() / 1000, rand = Math.random } = {}) {
|
|
93
|
+
if (typeof ceilingUsd !== 'number' || !Number.isFinite(ceilingUsd) || ceilingUsd < 0) {
|
|
94
|
+
throw new OpenRouterError('ceilingUsd is a finite number of dollars, 0 or more', { kind: 'config' });
|
|
95
|
+
}
|
|
96
|
+
if (!['day', 'month'].includes(window)) throw new OpenRouterError('window is day or month (UTC)', { kind: 'config' });
|
|
97
|
+
if (!ledger || typeof ledger.record !== 'function' || typeof ledger.receipts !== 'function') {
|
|
98
|
+
throw new OpenRouterError('ledger has record(receipt) and receipts(), as JsonlUsageLedger', { kind: 'config' });
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isInteger(retries) || retries < 0 || retries > 5) {
|
|
101
|
+
throw new OpenRouterError('retries is an integer from 0 to 5', { kind: 'config' });
|
|
102
|
+
}
|
|
103
|
+
http.checkUrl(baseUrl);
|
|
104
|
+
checkAttribution(projectId, undefined);
|
|
105
|
+
Object.assign(this, { ceilingUsd, ledger, keyEnv, window, attribution, projectId, unmeasuredCallUsd, deadlineS, retries });
|
|
106
|
+
this.prices = Object.fromEntries(Object.entries(prices ?? {}).map(([k, v]) => [k, { ...v }]));
|
|
107
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
108
|
+
this.env = env;
|
|
109
|
+
this.transport = transport;
|
|
110
|
+
this.sleep = sleep ?? ((s) => new Promise((resolve) => setTimeout(resolve, s * 1000)));
|
|
111
|
+
this.clock = clock;
|
|
112
|
+
this.now = now;
|
|
113
|
+
this.rand = rand;
|
|
114
|
+
this.inFlight = 0;
|
|
115
|
+
this.unmeasuredWorst = new Map(); // `${at} ${model}` of an unmeasured receipt -> that attempt's worst case
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
priced(model) {
|
|
119
|
+
const p = this.prices[model];
|
|
120
|
+
if (!isObject(p)) return null;
|
|
121
|
+
const prompt = price(p.prompt);
|
|
122
|
+
const completion = price(p.completion);
|
|
123
|
+
if (prompt === null || completion === null) return null;
|
|
124
|
+
return { prompt, completion, request: price(p.request) ?? 0 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
worstCaseUsd(model, messages, maxTokens, params = {}) {
|
|
128
|
+
const p = this.priced(model);
|
|
129
|
+
if (p === null) return null;
|
|
130
|
+
return promptTokensBound({ ...params, messages }) * p.prompt + maxTokens * p.completion + p.request;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
inWindow(at, today) {
|
|
134
|
+
const ms = time.parseRfc3339Ms(at);
|
|
135
|
+
if (Number.isNaN(ms)) return false;
|
|
136
|
+
const day = time.utcDay(ms);
|
|
137
|
+
return this.window === 'day' ? day === today : day.slice(0, 7) === today.slice(0, 7);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
spentUsd({ unmeasuredUsd = 0 } = {}) {
|
|
141
|
+
const today = time.utcDay(this.now() * 1000);
|
|
142
|
+
let total = 0;
|
|
143
|
+
for (const r of this.ledger.receipts()) {
|
|
144
|
+
if (!isObject(r) || !this.inWindow(r.at, today)) continue;
|
|
145
|
+
if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) total += r.costUsd;
|
|
146
|
+
else total += this.unmeasuredWorst.get(`${r.at} ${r.model}`) ?? unmeasuredUsd; // known here, else unmeasuredUsd
|
|
147
|
+
}
|
|
148
|
+
return total;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
reserve(model, messages, maxTokens, params) {
|
|
152
|
+
const worst = this.worstCaseUsd(model, messages, maxTokens, params);
|
|
153
|
+
if (worst === null) {
|
|
154
|
+
throw new OpenRouterError(`${model} has no price: its spend cannot be bounded, so it is not called`, { kind: 'unpriced' });
|
|
155
|
+
}
|
|
156
|
+
const unmeasured = this.unmeasuredCallUsd === null ? worst : Number(this.unmeasuredCallUsd);
|
|
157
|
+
const spent = this.spentUsd({ unmeasuredUsd: unmeasured }) + this.inFlight;
|
|
158
|
+
if (spent + worst > this.ceilingUsd) {
|
|
159
|
+
throw new OpenRouterError(`the ${this.window} ceiling of $${this.ceilingUsd.toFixed(4)} would be passed: spent `
|
|
160
|
+
+ `$${spent.toFixed(4)}, this attempt up to $${worst.toFixed(4)}`, { kind: 'ceiling' });
|
|
161
|
+
}
|
|
162
|
+
this.inFlight += worst; // synchronous with the check: no other call can pass between them
|
|
163
|
+
return worst;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
release(amount) { this.inFlight = Math.max(0, this.inFlight - amount); }
|
|
167
|
+
|
|
168
|
+
receipt(asked, body, taskRunId, worst) {
|
|
169
|
+
const served = typeof body.model === 'string' && body.model.trim() ? body.model : asked;
|
|
170
|
+
const usage = isObject(body.usage) ? body.usage : null;
|
|
171
|
+
const common = { at: time.formatRfc3339(Math.trunc(this.now() * 1000)), route: 'key', projectId: this.projectId, taskRunId };
|
|
172
|
+
const prompt = usage ? count(usage.prompt_tokens) : null;
|
|
173
|
+
const completion = usage ? count(usage.completion_tokens) : null;
|
|
174
|
+
if (prompt === null || completion === null) {
|
|
175
|
+
const unmeasured = makeUsageReceipt('openrouter', served.slice(0, 128), { inputTokens: 0, outputTokens: 0, tokensMeasured: false, ...common });
|
|
176
|
+
this.rememberUnmeasured(unmeasured, worst);
|
|
177
|
+
return unmeasured;
|
|
178
|
+
}
|
|
179
|
+
const details = isObject(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {};
|
|
180
|
+
const cached = count(details.cached_tokens) ?? 0;
|
|
181
|
+
const written = count(details.cache_write_tokens) ?? 0;
|
|
182
|
+
let cost = typeof usage.cost === 'number' ? price(usage.cost) : null;
|
|
183
|
+
let basis = 'provider';
|
|
184
|
+
if (cost === null) {
|
|
185
|
+
const p = this.priced(served) ?? this.priced(asked);
|
|
186
|
+
if (p) { cost = prompt * p.prompt + completion * p.completion; basis = 'price-list'; }
|
|
187
|
+
}
|
|
188
|
+
return makeUsageReceipt('openrouter', served.slice(0, 128), {
|
|
189
|
+
inputTokens: Math.max(0, prompt - cached - written), outputTokens: completion, cacheReadTokens: cached,
|
|
190
|
+
cacheWriteTokens: written, costUsd: cost, costBasis: cost === null ? undefined : basis, ...common,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A receipt for an attempt that may have been billed with no usage to read: no tokens, no cost.
|
|
195
|
+
unmeasured(asked, taskRunId, worst) {
|
|
196
|
+
const receipt = makeUsageReceipt('openrouter', asked.slice(0, 128), { inputTokens: 0, outputTokens: 0, tokensMeasured: false,
|
|
197
|
+
at: time.formatRfc3339(Math.trunc(this.now() * 1000)), route: 'key', projectId: this.projectId, taskRunId });
|
|
198
|
+
this.rememberUnmeasured(receipt, worst);
|
|
199
|
+
return receipt;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
rememberUnmeasured(receipt, worst) {
|
|
203
|
+
const key = `${receipt.at} ${receipt.model}`;
|
|
204
|
+
this.unmeasuredWorst.set(key, (this.unmeasuredWorst.get(key) ?? 0) + worst);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
key() {
|
|
208
|
+
const key = this.env[this.keyEnv] ?? '';
|
|
209
|
+
if (!key || /[\r\n\0 ]/.test(key)) throw new OpenRouterError(`no usable key in the environment variable ${this.keyEnv}`, { kind: 'config' });
|
|
210
|
+
return key;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
headers(key) {
|
|
214
|
+
const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
|
|
215
|
+
if (this.attribution !== null) {
|
|
216
|
+
Object.assign(headers, attributionHeaders(this.attribution, { url: this.baseUrl, originated: true, headers: {} }).headers);
|
|
217
|
+
}
|
|
218
|
+
return headers;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async chat(messages, { model, fallback, maxTokens, taskRunId, ...params } = {}) {
|
|
222
|
+
if (!Array.isArray(messages) || messages.length === 0) throw new OpenRouterError('messages is a non-empty list', { kind: 'config' });
|
|
223
|
+
if (!textOnly(messages)) {
|
|
224
|
+
throw new OpenRouterError('only text content is sent: an image, audio or file part has a cost the ceiling cannot bound', { kind: 'config' });
|
|
225
|
+
}
|
|
226
|
+
if (!vendor(model) || !vendor(fallback) || vendor(model) === vendor(fallback)) {
|
|
227
|
+
throw new OpenRouterError(`the fallback is a model of another vendor than ${model}`, { kind: 'config' });
|
|
228
|
+
}
|
|
229
|
+
if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 200_000) {
|
|
230
|
+
throw new OpenRouterError('maxTokens is an integer from 1 to 200000', { kind: 'config' });
|
|
231
|
+
}
|
|
232
|
+
checkAttribution(this.projectId, taskRunId); // before anything is sent: a billed call keeps its receipt
|
|
233
|
+
const clash = Object.keys(params).filter((k) => RESERVED_PARAMS.includes(k));
|
|
234
|
+
if (clash.length) throw new OpenRouterError(`the client sets ${clash.sort().join(', ')} itself`, { kind: 'config' });
|
|
235
|
+
const unbounded = Object.keys(params).filter((k) => UNBOUNDED_PARAMS.includes(k));
|
|
236
|
+
if (unbounded.length) {
|
|
237
|
+
throw new OpenRouterError(`${unbounded.sort().join(', ')} change what a call is billed for; the ceiling cannot bound them`, { kind: 'config' });
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
promptTokensBound({ ...params, messages });
|
|
241
|
+
} catch {
|
|
242
|
+
throw new OpenRouterError('the request holds a number JSON cannot write (NaN or Infinity)', { kind: 'config' });
|
|
243
|
+
}
|
|
244
|
+
const headers = this.headers(this.key());
|
|
245
|
+
const url = `${this.baseUrl}/chat/completions`;
|
|
246
|
+
const end = this.clock() + this.deadlineS;
|
|
247
|
+
const attempts = [];
|
|
248
|
+
for (const current of [model, fallback]) {
|
|
249
|
+
const body = { model: current, messages, max_tokens: maxTokens, usage: { include: true }, ...params };
|
|
250
|
+
for (let tried = 0; tried <= this.retries; tried += 1) {
|
|
251
|
+
const left = end - this.clock();
|
|
252
|
+
if (left <= 0) throw new OpenRouterError(`the deadline passed after ${attempts.length} attempts`, { kind: 'failed', attempts });
|
|
253
|
+
let held;
|
|
254
|
+
try {
|
|
255
|
+
held = this.reserve(current, messages, maxTokens, params);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
err.attempts = [...attempts];
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
let result;
|
|
261
|
+
try {
|
|
262
|
+
result = await this.attempt(url, headers, body, left, current, taskRunId, held);
|
|
263
|
+
} finally {
|
|
264
|
+
this.release(held);
|
|
265
|
+
}
|
|
266
|
+
attempts.push({ model: current, status: result.status, outcome: result.outcome });
|
|
267
|
+
if (result.answer) return { ...result.answer, attempts };
|
|
268
|
+
if (Object.values(STOP_AT_ONCE).includes(result.outcome)) {
|
|
269
|
+
throw new OpenRouterError(`OpenRouter refused the key or the request (${result.status})`, { kind: result.outcome, attempts });
|
|
270
|
+
}
|
|
271
|
+
if (!['retry', 'error'].includes(result.outcome) || tried === this.retries) break;
|
|
272
|
+
const wait = result.wait ?? http.backoffS(tried, { baseS: 0.5, maxS: 8, rand: this.rand() });
|
|
273
|
+
if (this.clock() + wait >= end) break;
|
|
274
|
+
await this.sleep(wait);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
throw new OpenRouterError(`no answer from ${model} or ${fallback}`, { kind: 'failed', attempts });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async attempt(url, headers, body, left, asked, taskRunId, worst) {
|
|
281
|
+
let response;
|
|
282
|
+
try {
|
|
283
|
+
response = await http.request('POST', url, { headers, body, deadlineS: left, retries: 0, transport: this.transport, clock: this.clock });
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (!(err instanceof http.HttpError)) throw err;
|
|
286
|
+
if (['timeout', 'connection'].includes(err.kind) || (err.kind === 'too-large' && err.status === 200)) {
|
|
287
|
+
// may have been billed (a cut connection looks like a refused one from here): counted, never as $0
|
|
288
|
+
this.ledger.record(this.unmeasured(asked, taskRunId, worst));
|
|
289
|
+
}
|
|
290
|
+
return { outcome: ['connection', 'timeout'].includes(err.kind) ? 'retry' : 'refused', status: err.status };
|
|
291
|
+
}
|
|
292
|
+
const { status } = response;
|
|
293
|
+
if (STOP_AT_ONCE[status]) return { outcome: STOP_AT_ONCE[status], status };
|
|
294
|
+
if (status !== 200) {
|
|
295
|
+
if (http.RETRY_STATUSES.includes(status)) return { outcome: 'retry', status, wait: http.retryAfterS(response.headers['retry-after'] ?? null) };
|
|
296
|
+
return { outcome: 'refused', status };
|
|
297
|
+
}
|
|
298
|
+
let parsed = null;
|
|
299
|
+
try { parsed = response.json(); } catch { /* read as no answer below */ }
|
|
300
|
+
if (!isObject(parsed)) {
|
|
301
|
+
this.ledger.record(this.unmeasured(asked, taskRunId, worst));
|
|
302
|
+
return { outcome: 'bad-answer', status };
|
|
303
|
+
}
|
|
304
|
+
let receipt;
|
|
305
|
+
try {
|
|
306
|
+
receipt = this.receipt(asked, parsed, taskRunId, worst);
|
|
307
|
+
} catch {
|
|
308
|
+
this.ledger.record(this.unmeasured(asked, taskRunId, worst)); // a billed answer it cannot read still counts
|
|
309
|
+
return { outcome: 'bad-answer', status };
|
|
310
|
+
}
|
|
311
|
+
this.ledger.record(receipt);
|
|
312
|
+
const choice = Array.isArray(parsed.choices) && isObject(parsed.choices[0]) ? parsed.choices[0] : {};
|
|
313
|
+
const message = isObject(choice.message) ? choice.message : {};
|
|
314
|
+
const finish = choice.finish_reason;
|
|
315
|
+
const content = typeof message.content === 'string' ? message.content : '';
|
|
316
|
+
const tools = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
317
|
+
if (finish === 'error' || isObject(parsed.error)) return { outcome: 'error', status };
|
|
318
|
+
if (finish === 'length') return { outcome: 'length', status };
|
|
319
|
+
if (finish === 'content_filter') return { outcome: 'filtered', status };
|
|
320
|
+
if (!ANSWERED.includes(finish) || !(content.trim() || tools.length)) return { outcome: 'empty', status };
|
|
321
|
+
return { outcome: 'answered', status, answer: { content, message, model: receipt.model, finishReason: finish, receipt } };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Calls, priced spend and unpriced calls over receipts; spentUsd is null when every call is unpriced.
|
|
326
|
+
export function totals(receipts) {
|
|
327
|
+
let calls = 0;
|
|
328
|
+
let priced = 0;
|
|
329
|
+
let spent = 0;
|
|
330
|
+
for (const r of receipts) {
|
|
331
|
+
calls += 1;
|
|
332
|
+
if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) { priced += 1; spent += r.costUsd; }
|
|
333
|
+
}
|
|
334
|
+
return { calls, spentUsd: priced ? spent : null, unpricedCalls: calls - priced };
|
|
335
|
+
}
|