@cspeach/cli 1.1.33 → 1.1.34
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/dist/agent/anthropic-provider.js +2 -0
- package/dist/agent/azure-profile.js +7 -0
- package/dist/agent/connection-profile.js +49 -0
- package/dist/agent/loop.js +61 -8
- package/dist/agent/provider-shape.js +36 -0
- package/dist/agent/providers/ai-hub-provider.js +6 -5
- package/dist/agent/providers/azure-provider.js +125 -0
- package/dist/agent/providers/byok-provider.js +8 -7
- package/dist/agent/providers/factory.js +8 -3
- package/dist/agent/providers/local-provider.js +18 -22
- package/dist/agent/risk-gate.js +202 -0
- package/dist/agent/summarise-via-provider.js +55 -8
- package/dist/agent/tool-dispatch.js +19 -0
- package/dist/agent/turn-error-ux.js +22 -0
- package/dist/auth/api-key.js +10 -5
- package/dist/auth/auth-file.js +35 -6
- package/dist/auth/azure/azure-key.js +21 -0
- package/dist/auth/azure/azure-log.js +45 -0
- package/dist/auth/azure/cache-store.js +171 -0
- package/dist/auth/azure/clear.js +28 -0
- package/dist/auth/azure/loopback.js +192 -0
- package/dist/auth/azure/msal-client.js +41 -0
- package/dist/auth/azure/sign-in.js +219 -0
- package/dist/auth/azure/startup-sign-in.js +64 -0
- package/dist/auth/azure/token-provider.js +116 -0
- package/dist/auth/key-resolver.js +258 -0
- package/dist/auth/me.js +11 -5
- package/dist/cli.js +21 -2
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +74 -3
- package/dist/commands/config-show.js +25 -0
- package/dist/commands/cost.js +19 -6
- package/dist/commands/help.js +2 -2
- package/dist/commands/login-azure.js +33 -0
- package/dist/commands/login.js +56 -8
- package/dist/commands/logout.js +34 -10
- package/dist/commands/plan-boundary.js +4 -4
- package/dist/commands/plan-model-tier.js +8 -3
- package/dist/commands/usage.js +6 -0
- package/dist/commands/whoami.js +113 -9
- package/dist/config/loader.js +5 -0
- package/dist/connection/azure-auth.js +135 -0
- package/dist/connection/azure-errors.js +103 -0
- package/dist/connection/azure-facts.js +21 -0
- package/dist/connection/azure-gate.js +40 -0
- package/dist/connection/azure-last-call.js +5 -0
- package/dist/connection/azure-turn-error.js +25 -0
- package/dist/connection/cache.js +70 -0
- package/dist/connection/fetch.js +33 -0
- package/dist/connection/guard.js +94 -0
- package/dist/connection/limit-gate.js +26 -0
- package/dist/connection/local-usage.js +71 -0
- package/dist/connection/login-step.js +62 -0
- package/dist/connection/startup.js +67 -0
- package/dist/connection/state.js +68 -0
- package/dist/connection/types.js +1 -0
- package/dist/cost/cost-log.js +3 -1
- package/dist/cost/credits-wire.js +1 -1
- package/dist/cost/credits.js +4 -0
- package/dist/cost/display-mode.js +7 -0
- package/dist/cost/router-spend.js +3 -0
- package/dist/cost/session-cost.js +4 -0
- package/dist/doctor/checks/azure.js +100 -0
- package/dist/doctor/checks/credits.js +4 -3
- package/dist/doctor/checks/usage-reporting.js +22 -0
- package/dist/doctor/run.js +7 -0
- package/dist/licence/licence-check.js +205 -0
- package/dist/licence/paths.js +6 -0
- package/dist/licence/turn-gate.js +66 -0
- package/dist/models/model-identity.js +65 -0
- package/dist/models/resolve.js +8 -0
- package/dist/models/server-config.js +3 -2
- package/dist/one-shot.js +86 -2
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/repl/ask-or-no.js +18 -0
- package/dist/repl/builtin-commands.js +10 -2
- package/dist/repl/post-turn-status.js +3 -2
- package/dist/repl/safety-confirm.js +47 -2
- package/dist/repl/session-spend-line.js +4 -1
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +211 -48
- package/dist/router/byok-classifier.js +41 -14
- package/dist/router/classifier.js +5 -16
- package/dist/router/router-target.js +41 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/session/audit-export.js +11 -4
- package/dist/skills/manifest-client.js +37 -4
- package/dist/skills/skills-cache.js +115 -0
- package/dist/skills/source-authed.js +276 -0
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/tool-loading.js +9 -10
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +3 -2
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- package/dist/skills/source-manifest.js +0 -36
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security batch item 3 — <CSPeach home>/usage-queue.jsonl: usage counts
|
|
3
|
+
* waiting to be sent. Capped at 1,000 lines or 1 MB (oldest dropped).
|
|
4
|
+
*
|
|
5
|
+
* Safe with several cspeach processes: every read-modify-write holds an
|
|
6
|
+
* exclusive lock file (O_EXCL create). The holder writes "<pid> <ms> <host>
|
|
7
|
+
* <token>"; a contender clears the lock at once when that pid is gone on this
|
|
8
|
+
* host, or when the lock is older than 10 s. A contender waits longer than
|
|
9
|
+
* the stale age, so an orphaned lock never drops a record (F5).
|
|
10
|
+
*
|
|
11
|
+
* The network send happens OUTSIDE the lock as a lease (F1): leaseBatch
|
|
12
|
+
* returns the oldest records WITHOUT removing them; ackBatch removes exactly
|
|
13
|
+
* those request ids once the proxy answered. A crash mid-send therefore
|
|
14
|
+
* resends the batch next time, and the proxy drops the repeat by request id.
|
|
15
|
+
*
|
|
16
|
+
* Each line carries a local-only `keyPrefix` (a short sha256 fingerprint of
|
|
17
|
+
* the seat's key, never the key or a piece of it). leaseBatch keeps only the
|
|
18
|
+
* current seat's lines, drops the rest, and strips the tag: it is never sent
|
|
19
|
+
* (F10).
|
|
20
|
+
*/
|
|
21
|
+
import { promises as fs } from 'node:fs';
|
|
22
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
26
|
+
import { countedTokens } from '../connection/local-usage.js';
|
|
27
|
+
/** The exact fields sent; anything else on a queue line stays on disk. */
|
|
28
|
+
const RECORD_KEYS = [
|
|
29
|
+
'request_id', 'mode', 'skill', 'model', 'input_tokens', 'output_tokens',
|
|
30
|
+
'cache_read_tokens', 'cache_write_tokens', 'cli_version', 'session_id', 'at',
|
|
31
|
+
];
|
|
32
|
+
export const QUEUE_MAX_LINES = 1000;
|
|
33
|
+
export const QUEUE_MAX_BYTES = 1_000_000;
|
|
34
|
+
const LOCK_STALE_MS = 10_000;
|
|
35
|
+
/** Longer than LOCK_STALE_MS: a holder that died is always outwaited (F5). */
|
|
36
|
+
const LOCK_WAIT_MS = 15_000;
|
|
37
|
+
/**
|
|
38
|
+
* Final review finding 6 — the rename retry runs while the lock is held, so it
|
|
39
|
+
* must give up before a contender may judge that lock stale (LOCK_STALE_MS):
|
|
40
|
+
* otherwise two writers are possible in the last seconds.
|
|
41
|
+
*/
|
|
42
|
+
const RENAME_WAIT_MS = 8_000;
|
|
43
|
+
/** A queue temp file older than this is a crash leftover (a live write renames within ms). */
|
|
44
|
+
const STALE_TMP_MS = 60_000;
|
|
45
|
+
/** Pinned by tests: RENAME_WAIT_MS < LOCK_STALE_MS < LOCK_WAIT_MS. */
|
|
46
|
+
export const QUEUE_TIMINGS = { LOCK_STALE_MS, LOCK_WAIT_MS, RENAME_WAIT_MS };
|
|
47
|
+
const LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
48
|
+
export function usageQueuePath() {
|
|
49
|
+
return path.join(cspeachRoot(), 'usage-queue.jsonl');
|
|
50
|
+
}
|
|
51
|
+
const lockPath = () => `${usageQueuePath()}.lock`;
|
|
52
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
53
|
+
/** The seat tag on a queue line: a fingerprint of the key, never the key. */
|
|
54
|
+
export function keyPrefixFor(key) {
|
|
55
|
+
return createHash('sha256').update(`cspeach-usage-seat:${key}`).digest('hex').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
/** True only when the lock's holder is provably gone (same host, pid not running). */
|
|
58
|
+
function holderIsDead(content) {
|
|
59
|
+
const [pidText, , host] = content.trim().split(' ');
|
|
60
|
+
const pid = Number(pidText);
|
|
61
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
|
|
62
|
+
return false;
|
|
63
|
+
if (host !== os.hostname())
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return err.code === 'ESRCH';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Remove a lock judged dead or stale. Renamed to a unique name first (only one
|
|
75
|
+
* contender can win the rename); if what was renamed is not the lock that was
|
|
76
|
+
* judged (a fresh holder took over in between), it is put back.
|
|
77
|
+
*/
|
|
78
|
+
async function clearLock(lp, judged) {
|
|
79
|
+
const aside = `${lp}.${process.pid}.${randomUUID()}.stale`;
|
|
80
|
+
try {
|
|
81
|
+
await fs.rename(lp, aside);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const got = await fs.readFile(aside, 'utf-8').catch(() => judged);
|
|
87
|
+
if (got !== judged)
|
|
88
|
+
await fs.link(aside, lp).catch(() => { });
|
|
89
|
+
await fs.rm(aside, { force: true }).catch(() => { });
|
|
90
|
+
}
|
|
91
|
+
async function withQueueLock(fn) {
|
|
92
|
+
const lp = lockPath();
|
|
93
|
+
await fs.mkdir(path.dirname(lp), { recursive: true });
|
|
94
|
+
const token = `${process.pid} ${Date.now()} ${os.hostname()} ${randomUUID()}`;
|
|
95
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
96
|
+
for (;;) {
|
|
97
|
+
try {
|
|
98
|
+
const h = await fs.open(lp, 'wx');
|
|
99
|
+
try {
|
|
100
|
+
await h.writeFile(token);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await h.close();
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const code = err.code ?? '';
|
|
109
|
+
// F14 — on Windows a lock file being created or deleted by another
|
|
110
|
+
// process answers EPERM / EACCES / EBUSY instead of EEXIST: that is
|
|
111
|
+
// contention too, so wait and retry (never drop the record).
|
|
112
|
+
if (code === 'EEXIST') {
|
|
113
|
+
try {
|
|
114
|
+
const [content, st] = await Promise.all([fs.readFile(lp, 'utf-8'), fs.stat(lp)]);
|
|
115
|
+
if (holderIsDead(content) || Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
116
|
+
await clearLock(lp, content);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch { /* gone or busy: retry after the pause */ }
|
|
121
|
+
}
|
|
122
|
+
else if (!LOCK_CODES.has(code)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
if (Date.now() > deadline)
|
|
126
|
+
return undefined;
|
|
127
|
+
await sleep(10 + Math.floor(Math.random() * 20));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await sweepStaleTemps();
|
|
131
|
+
try {
|
|
132
|
+
return await fn();
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
// Release only our own lock (it may have been cleared as stale meanwhile).
|
|
136
|
+
const now = await fs.readFile(lp, 'utf-8').catch(() => '');
|
|
137
|
+
if (now === token)
|
|
138
|
+
await fs.unlink(lp).catch(() => { });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Task 9 I3 — remove usage-queue.jsonl.<pid>.tmp files left by a crash.
|
|
143
|
+
* Runs under the queue lock; only our own temp names, only when older than
|
|
144
|
+
* STALE_TMP_MS (same rule as the skills cache). Never throws.
|
|
145
|
+
*/
|
|
146
|
+
async function sweepStaleTemps() {
|
|
147
|
+
const p = usageQueuePath();
|
|
148
|
+
const dir = path.dirname(p);
|
|
149
|
+
const prefix = `${path.basename(p)}.`;
|
|
150
|
+
try {
|
|
151
|
+
for (const f of await fs.readdir(dir)) {
|
|
152
|
+
if (!f.startsWith(prefix) || !/^\d+\.tmp$/.test(f.slice(prefix.length)))
|
|
153
|
+
continue;
|
|
154
|
+
const full = path.join(dir, f);
|
|
155
|
+
const st = await fs.stat(full).catch(() => null);
|
|
156
|
+
if (st && Date.now() - st.mtimeMs > STALE_TMP_MS)
|
|
157
|
+
await fs.rm(full, { force: true }).catch(() => { });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* no directory yet */ }
|
|
161
|
+
}
|
|
162
|
+
async function readLines() {
|
|
163
|
+
try {
|
|
164
|
+
return (await fs.readFile(usageQueuePath(), 'utf-8')).split('\n').filter((l) => l.trim().length > 0);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function writeLines(lines) {
|
|
171
|
+
const p = usageQueuePath();
|
|
172
|
+
if (lines.length === 0) {
|
|
173
|
+
await fs.rm(p, { force: true });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
177
|
+
await fs.writeFile(tmp, lines.join('\n') + '\n', 'utf-8');
|
|
178
|
+
// F14 — a reader outside the lock (queueStats, doctor) can hold the file
|
|
179
|
+
// open on Windows for a moment: EPERM / EACCES / EBUSY is retried until a
|
|
180
|
+
// deadline, never treated as a lost write.
|
|
181
|
+
const deadline = Date.now() + RENAME_WAIT_MS;
|
|
182
|
+
for (let attempt = 0;; attempt++) {
|
|
183
|
+
try {
|
|
184
|
+
await fs.rename(tmp, p);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (!LOCK_CODES.has(err.code ?? '') || Date.now() > deadline) {
|
|
189
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
await sleep(Math.min(20 * (attempt + 1), 200));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Newest lines kept: at most QUEUE_MAX_LINES and QUEUE_MAX_BYTES (newline included). */
|
|
197
|
+
export function capLines(lines) {
|
|
198
|
+
let out = lines.slice(-QUEUE_MAX_LINES);
|
|
199
|
+
let bytes = out.reduce((n, l) => n + Buffer.byteLength(l) + 1, 0);
|
|
200
|
+
while (out.length > 0 && bytes > QUEUE_MAX_BYTES) {
|
|
201
|
+
bytes -= Buffer.byteLength(out[0]) + 1;
|
|
202
|
+
out = out.slice(1);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
/** A line → the record (exactly RECORD_KEYS) and its seat tag; null when unreadable. */
|
|
207
|
+
function parseLine(l) {
|
|
208
|
+
try {
|
|
209
|
+
const o = JSON.parse(l);
|
|
210
|
+
if (!o || typeof o !== 'object' || Array.isArray(o) || typeof o.request_id !== 'string')
|
|
211
|
+
return null;
|
|
212
|
+
const rec = Object.fromEntries(RECORD_KEYS.map((k) => [k, o[k]]));
|
|
213
|
+
return { rec, keyPrefix: typeof o.keyPrefix === 'string' ? o.keyPrefix : '' };
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** True when the record is in the queue, tagged with its seat. Never throws. */
|
|
220
|
+
export async function enqueueUsage(rec, keyPrefix) {
|
|
221
|
+
try {
|
|
222
|
+
const line = JSON.stringify({ ...rec, keyPrefix });
|
|
223
|
+
return (await withQueueLock(async () => { await writeLines(capLines([...(await readLines()), line])); return true; })) === true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Up to `max` of the oldest records of this seat, NOT removed (F1). Lines
|
|
231
|
+
* that cannot be read and lines of another seat are dropped here (F10); the
|
|
232
|
+
* returned records never carry the seat tag. Never throws.
|
|
233
|
+
*/
|
|
234
|
+
export async function leaseBatch(max, keyPrefix) {
|
|
235
|
+
try {
|
|
236
|
+
return (await withQueueLock(async () => {
|
|
237
|
+
const lines = await readLines();
|
|
238
|
+
const keep = [];
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const l of lines) {
|
|
241
|
+
const q = parseLine(l);
|
|
242
|
+
if (!q || q.keyPrefix !== keyPrefix)
|
|
243
|
+
continue;
|
|
244
|
+
keep.push(l);
|
|
245
|
+
if (out.length < max)
|
|
246
|
+
out.push(q.rec);
|
|
247
|
+
}
|
|
248
|
+
if (keep.length !== lines.length)
|
|
249
|
+
await writeLines(keep);
|
|
250
|
+
return out;
|
|
251
|
+
})) ?? [];
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Remove the records with these request ids (the proxy answered for them). False when the queue could not be changed. */
|
|
258
|
+
export async function ackBatch(requestIds) {
|
|
259
|
+
if (requestIds.length === 0)
|
|
260
|
+
return true;
|
|
261
|
+
const done = new Set(requestIds);
|
|
262
|
+
try {
|
|
263
|
+
return (await withQueueLock(async () => {
|
|
264
|
+
const lines = await readLines();
|
|
265
|
+
const keep = lines.filter((l) => { const q = parseLine(l); return q !== null && !done.has(q.rec.request_id); });
|
|
266
|
+
if (keep.length !== lines.length)
|
|
267
|
+
await writeLines(keep);
|
|
268
|
+
return true;
|
|
269
|
+
})) === true;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export async function queueStats() {
|
|
276
|
+
try {
|
|
277
|
+
const text = await fs.readFile(usageQueuePath(), 'utf-8');
|
|
278
|
+
return { lines: text.split('\n').filter((l) => l.trim().length > 0).length, bytes: Buffer.byteLength(text) };
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { lines: 0, bytes: 0 };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Azure limit (D6) — the counted tokens (input + output + cache-write; cache
|
|
286
|
+
* reads excluded) of THIS seat's records still waiting in the queue: exactly
|
|
287
|
+
* the tokens the server's `used` does not hold yet. Only azure records count,
|
|
288
|
+
* as the server's `used` counts azure usage only. Reads without the lock,
|
|
289
|
+
* as queueStats does. No file → 0; any other read error throws, so the caller
|
|
290
|
+
* keeps its count. Unreadable lines, other seats' lines and other modes are skipped.
|
|
291
|
+
*/
|
|
292
|
+
export async function queuedTokens(keyPrefix) {
|
|
293
|
+
let text;
|
|
294
|
+
try {
|
|
295
|
+
text = await fs.readFile(usageQueuePath(), 'utf-8');
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
if (err.code === 'ENOENT')
|
|
299
|
+
return 0;
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
303
|
+
let sum = 0;
|
|
304
|
+
for (const l of text.split('\n')) {
|
|
305
|
+
if (l.trim().length === 0)
|
|
306
|
+
continue;
|
|
307
|
+
const q = parseLine(l);
|
|
308
|
+
if (!q || q.keyPrefix !== keyPrefix || q.rec.mode !== 'azure')
|
|
309
|
+
continue;
|
|
310
|
+
sum += countedTokens({ input_tokens: n(q.rec.input_tokens), output_tokens: n(q.rec.output_tokens), cache_write_tokens: n(q.rec.cache_write_tokens) });
|
|
311
|
+
}
|
|
312
|
+
return sum;
|
|
313
|
+
}
|
|
314
|
+
/** Cheap when there is nothing to delete (no lock taken): called on every call while reporting is off. */
|
|
315
|
+
export async function deleteUsageQueue() {
|
|
316
|
+
try {
|
|
317
|
+
await fs.access(usageQueuePath());
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
await withQueueLock(async () => { await fs.rm(usageQueuePath(), { force: true }); }).catch(() => { });
|
|
323
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security batch item 3 — usage counts from byok / ai-hub / local model
|
|
3
|
+
* calls (managed calls are counted by the proxy). Counts only: seat (the
|
|
4
|
+
* bearer), skill, model, four token counters, turn time, CLI version, session
|
|
5
|
+
* id, request id. Never prompts, responses, source, object names, SAP system
|
|
6
|
+
* ids or file paths. Queued, sent in background batches; never blocks a turn.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { loadConfig } from '../config/loader.js';
|
|
10
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
11
|
+
import { enqueueUsage, leaseBatch, ackBatch, deleteUsageQueue, keyPrefixFor } from './usage-queue.js';
|
|
12
|
+
import { countedTokens, addLocalUsage } from '../connection/local-usage.js';
|
|
13
|
+
/**
|
|
14
|
+
* The dated default (spec item 3, "Rollout"): with usage_reporting unset,
|
|
15
|
+
* reporting is OFF before this instant and ON from it — the 2 weeks' notice.
|
|
16
|
+
* Task 13 sets it to the publish day + 14 days.
|
|
17
|
+
*/
|
|
18
|
+
export const USAGE_REPORTING_DEFAULT_ON_FROM = '2026-10-26T00:00:00.000Z';
|
|
19
|
+
export const BATCH_SIZE = 100;
|
|
20
|
+
const FLUSH_DELAY_MS = 1500;
|
|
21
|
+
const SEND_TIMEOUT_MS = 5000;
|
|
22
|
+
const MAX_ROUNDS = 10;
|
|
23
|
+
/** Statuses that mean "this batch will never be accepted": drop it, never retry (F3). */
|
|
24
|
+
const DROP_STATUSES = new Set([400, 413, 422]);
|
|
25
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
26
|
+
/** The proxy's per-field limits (cspeach-proxy/src/routes/usage.ts): a label it would reject is made safe here, never dropped. */
|
|
27
|
+
const MAX_TOKENS = 50_000_000;
|
|
28
|
+
const VERSION_RE = /^(\d{1,4})\.(\d{1,5})\.(\d{1,6})(?:[-+].*)?$/;
|
|
29
|
+
function label(v, bad, max, fallback) {
|
|
30
|
+
const s = (typeof v === 'string' ? v : '').replace(bad, '-').slice(0, max);
|
|
31
|
+
return s.length > 0 ? s : fallback;
|
|
32
|
+
}
|
|
33
|
+
const count = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.min(Math.floor(v), MAX_TOKENS) : 0;
|
|
34
|
+
/** F6 — x.y.z the proxy accepts: prerelease / build metadata stripped; anything else '0.0.0'. */
|
|
35
|
+
export function normaliseCliVersion(v) {
|
|
36
|
+
const m = VERSION_RE.exec(String(v ?? '').trim());
|
|
37
|
+
return m ? `${m[1]}.${m[2]}.${m[3]}` : '0.0.0';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* F7 — a local model may be configured as a file path (a gguf on disk). Only
|
|
41
|
+
* its file name, without the extension, is reported; a plain model name
|
|
42
|
+
* (`llama3.1:70b`) has no path and is left as it is.
|
|
43
|
+
*/
|
|
44
|
+
function localModelName(m) {
|
|
45
|
+
if (!/[\\/]/.test(m) && !/^[A-Za-z]:/.test(m))
|
|
46
|
+
return m;
|
|
47
|
+
const base = m.split(/[\\/]/).pop() ?? '';
|
|
48
|
+
// Only real model-file extensions (final review, Task 9 I2): a version dot
|
|
49
|
+
// in a name such as qwen2.5 is part of the name.
|
|
50
|
+
return base.replace(/^[A-Za-z]:/, '').replace(/\.(?:gguf|bin|safetensors|pt|pth|onnx)$/i, '');
|
|
51
|
+
}
|
|
52
|
+
export function usageReportingState(cfg, now = new Date(), onFrom = USAGE_REPORTING_DEFAULT_ON_FROM, companyRequired = false) {
|
|
53
|
+
// Azure tenant mode, Task 8b (plan D11): the company's settings say usage
|
|
54
|
+
// counts are required — on, whatever the dated default or a stored "off".
|
|
55
|
+
if (companyRequired)
|
|
56
|
+
return { on: true, why: 'company-required' };
|
|
57
|
+
if (cfg.usage_reporting === 'on')
|
|
58
|
+
return { on: true, why: 'set-on' };
|
|
59
|
+
if (cfg.usage_reporting === 'off')
|
|
60
|
+
return { on: false, why: 'set-off' };
|
|
61
|
+
return now.getTime() >= Date.parse(onFrom) ? { on: true, why: 'default-on' } : { on: false, why: 'default-off-until', onFrom };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The one record shape POST /v1/usage takes — built field by field, so
|
|
65
|
+
* nothing else a caller passes can ride along. Null for managed calls (the
|
|
66
|
+
* proxy counts those) and for a call that never started. A started call
|
|
67
|
+
* with zero tokens (a local server that reports no usage) is still a turn
|
|
68
|
+
* and is recorded with zeros (F2).
|
|
69
|
+
*/
|
|
70
|
+
export function usageRecordFor(c) {
|
|
71
|
+
if (c.mode !== 'byok' && c.mode !== 'ai-hub' && c.mode !== 'local' && c.mode !== 'azure')
|
|
72
|
+
return null;
|
|
73
|
+
if (c.started === false)
|
|
74
|
+
return null;
|
|
75
|
+
const model = c.mode === 'local' && typeof c.model === 'string' ? localModelName(c.model) : c.model;
|
|
76
|
+
return {
|
|
77
|
+
request_id: typeof c.requestId === 'string' && REQUEST_ID_RE.test(c.requestId) ? c.requestId : randomUUID(),
|
|
78
|
+
mode: c.mode,
|
|
79
|
+
skill: label(c.skill, /[^A-Za-z0-9_-]/g, 64, 'none'),
|
|
80
|
+
model: label(model, /[^A-Za-z0-9._:/@-]/g, 100, 'unknown'),
|
|
81
|
+
input_tokens: count(c.input),
|
|
82
|
+
output_tokens: count(c.output),
|
|
83
|
+
cache_read_tokens: count(c.cacheRead),
|
|
84
|
+
cache_write_tokens: count(c.cacheWrite),
|
|
85
|
+
cli_version: normaliseCliVersion(CLI_VERSION),
|
|
86
|
+
session_id: label(c.sessionId, /[^A-Za-z0-9_-]/g, 64, 'none'),
|
|
87
|
+
at: (c.at ?? new Date()).toISOString(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Azure tenant mode, Task 8b — why the company keeps usage counts on for this
|
|
92
|
+
* seat, or null when the developer's switch applies:
|
|
93
|
+
* - 'required': the company's settings require usage counts;
|
|
94
|
+
* - 'limit': this seat has a monthly limit (fix round 1, ruling 1), which
|
|
95
|
+
* needs the counts even when the company made them optional.
|
|
96
|
+
* Only azure mode reads it; every other mode answers null with no I/O.
|
|
97
|
+
* Settings the company removed no longer keep anything on.
|
|
98
|
+
*/
|
|
99
|
+
export async function companyUsageRule(cfg) {
|
|
100
|
+
if (cfg.llm?.mode !== 'azure')
|
|
101
|
+
return null;
|
|
102
|
+
const { readConnectionCache } = await import('../connection/cache.js');
|
|
103
|
+
const c = await readConnectionCache().catch(() => null);
|
|
104
|
+
if (!c || c.removed)
|
|
105
|
+
return null;
|
|
106
|
+
if (c.connection.usage_counts === 'required')
|
|
107
|
+
return 'required';
|
|
108
|
+
return c.connection.limit ? 'limit' : null;
|
|
109
|
+
}
|
|
110
|
+
/** True when the company keeps usage counts on for this seat (required, or a monthly limit). */
|
|
111
|
+
export async function companyRequiresUsageCounts(cfg) {
|
|
112
|
+
return (await companyUsageRule(cfg)) !== null;
|
|
113
|
+
}
|
|
114
|
+
/** Only azure asks the company; every other mode keeps today's switch exactly. */
|
|
115
|
+
async function requiredFor(cfg, deps) {
|
|
116
|
+
if (cfg.llm?.mode !== 'azure')
|
|
117
|
+
return false;
|
|
118
|
+
return (deps.companyRequired ?? companyRequiresUsageCounts)(cfg).catch(() => false);
|
|
119
|
+
}
|
|
120
|
+
async function defaultGetKey() {
|
|
121
|
+
const { getStore } = await import('../auth/api-key.js');
|
|
122
|
+
return (await getStore()).get();
|
|
123
|
+
}
|
|
124
|
+
let flushTimer = null;
|
|
125
|
+
function scheduleDefault(run) {
|
|
126
|
+
if (flushTimer)
|
|
127
|
+
return;
|
|
128
|
+
flushTimer = setTimeout(() => { flushTimer = null; run(); }, FLUSH_DELAY_MS);
|
|
129
|
+
flushTimer.unref?.();
|
|
130
|
+
}
|
|
131
|
+
/** After each non-managed model call. Never throws; never awaits the network. */
|
|
132
|
+
export async function reportCallUsage(c, deps = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const rec = usageRecordFor(c);
|
|
135
|
+
if (!rec)
|
|
136
|
+
return;
|
|
137
|
+
// Task 8b — the monthly limit counts every azure call on this laptop,
|
|
138
|
+
// whether or not the counts are reported (honest-developer control).
|
|
139
|
+
if (rec.mode === 'azure')
|
|
140
|
+
await (deps.addLocal ?? ((n) => addLocalUsage(n)))(countedTokens(rec)).catch(() => { });
|
|
141
|
+
const cfg = await (deps.loadConfig ?? loadConfig)();
|
|
142
|
+
if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))(), undefined, await requiredFor(cfg, deps)).on) {
|
|
143
|
+
// Off: no queue file is written, and an existing one is deleted.
|
|
144
|
+
await deleteUsageQueue();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// F10 — a record belongs to the seat that made the call; not signed in → no record.
|
|
148
|
+
const key = await (deps.getKey ?? defaultGetKey)();
|
|
149
|
+
if (!key)
|
|
150
|
+
return;
|
|
151
|
+
if (!(await enqueueUsage(rec, keyPrefixFor(key))))
|
|
152
|
+
return;
|
|
153
|
+
(deps.scheduleFlush ?? scheduleDefault)(() => {
|
|
154
|
+
try {
|
|
155
|
+
void flushUsageQueue(cfg.proxy_url, deps).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
catch { /* best effort */ }
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch { /* usage counts are best effort */ }
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Send this seat's queued records in batches of at most 100 to the CSPeach
|
|
164
|
+
* proxy, with the seat's key. Re-checks first (F10): a managed seat, a seat
|
|
165
|
+
* with reporting off, or no key sends nothing. Each batch is leased (F1): it
|
|
166
|
+
* stays on disk until the proxy answered, then exactly those request ids are
|
|
167
|
+
* removed. 2xx → removed; 400 / 413 / 422 → removed (never accepted, F3);
|
|
168
|
+
* anything else or no answer → kept for later. Never logs the key.
|
|
169
|
+
*/
|
|
170
|
+
export async function flushUsageQueue(proxyUrl, deps = {}) {
|
|
171
|
+
const none = { sent: 0, kept: 0 };
|
|
172
|
+
let cfg;
|
|
173
|
+
try {
|
|
174
|
+
cfg = await (deps.loadConfig ?? loadConfig)();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return none;
|
|
178
|
+
}
|
|
179
|
+
if ((cfg.llm?.mode ?? 'managed') === 'managed')
|
|
180
|
+
return none;
|
|
181
|
+
if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))(), undefined, await requiredFor(cfg, deps)).on)
|
|
182
|
+
return none;
|
|
183
|
+
let key;
|
|
184
|
+
try {
|
|
185
|
+
key = await (deps.getKey ?? defaultGetKey)();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
key = null;
|
|
189
|
+
}
|
|
190
|
+
if (!key)
|
|
191
|
+
return none;
|
|
192
|
+
const seat = keyPrefixFor(key);
|
|
193
|
+
const ack = deps.ack ?? ackBatch;
|
|
194
|
+
let sent = 0;
|
|
195
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
196
|
+
const batch = await leaseBatch(BATCH_SIZE, seat);
|
|
197
|
+
if (batch.length === 0)
|
|
198
|
+
break;
|
|
199
|
+
const ids = batch.map((r) => r.request_id);
|
|
200
|
+
let r;
|
|
201
|
+
try {
|
|
202
|
+
r = await (deps.fetchImpl ?? fetch)(`${proxyUrl.replace(/\/$/, '')}/v1/usage`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json', 'User-Agent': `cspeach/${CLI_VERSION}` },
|
|
205
|
+
body: JSON.stringify({ records: batch }),
|
|
206
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return { sent, kept: batch.length }; // offline: the lease simply lapses; nothing was removed
|
|
211
|
+
}
|
|
212
|
+
// F8 — always read the body (frees the socket); the rejected count is a
|
|
213
|
+
// debug line only, and never carries the key.
|
|
214
|
+
const text = await r.text().catch(() => '');
|
|
215
|
+
if (r.ok) {
|
|
216
|
+
sent += batch.length;
|
|
217
|
+
if (process.env.CSPEACH_DEBUG === '1') {
|
|
218
|
+
let rejected = 0;
|
|
219
|
+
try {
|
|
220
|
+
rejected = Number(JSON.parse(text).rejected) || 0;
|
|
221
|
+
}
|
|
222
|
+
catch { /* not JSON */ }
|
|
223
|
+
if (rejected > 0)
|
|
224
|
+
process.stderr.write(`[usage] the proxy rejected ${rejected} usage record(s)\n`);
|
|
225
|
+
}
|
|
226
|
+
// A failed removal keeps the batch for a resend (the proxy dedupes);
|
|
227
|
+
// stop here rather than lease the same batch again this flush.
|
|
228
|
+
if (!(await ack(ids)))
|
|
229
|
+
return { sent, kept: 0 };
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (DROP_STATUSES.has(r.status)) {
|
|
233
|
+
if (!(await ack(ids)))
|
|
234
|
+
return { sent, kept: 0 };
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
return { sent, kept: batch.length }; // 401 / 403 / 404 / 429 / 5xx: keep for later
|
|
238
|
+
}
|
|
239
|
+
return { sent, kept: 0 };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Controller ruling (Task 9 fix round 1) — at REPL / one-shot start, a
|
|
243
|
+
* non-managed seat with reporting on sends what is waiting. Fire-and-forget:
|
|
244
|
+
* returns at once (true when a flush was started), never throws, adds no
|
|
245
|
+
* start-up latency. Managed: returns false with no I/O at all.
|
|
246
|
+
*/
|
|
247
|
+
export function startUsageFlushAtStartup(cfg, deps = {}) {
|
|
248
|
+
try {
|
|
249
|
+
if ((cfg.llm?.mode ?? 'managed') === 'managed')
|
|
250
|
+
return false;
|
|
251
|
+
const flush = deps.flush ?? ((url) => flushUsageQueue(url));
|
|
252
|
+
if (cfg.llm?.mode === 'azure') {
|
|
253
|
+
// Task 8b — whether the company requires counts is read from disk, so
|
|
254
|
+
// the decision is async: fire-and-forget like the flush itself.
|
|
255
|
+
void (async () => {
|
|
256
|
+
if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))(), undefined, await requiredFor(cfg, deps)).on)
|
|
257
|
+
return;
|
|
258
|
+
await flush(cfg.proxy_url);
|
|
259
|
+
})().catch(() => { });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))()).on)
|
|
263
|
+
return false;
|
|
264
|
+
void Promise.resolve(flush(cfg.proxy_url)).catch(() => { });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access,
|
|
3
|
+
"version": "1.1.34",
|
|
4
|
+
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
7
7
|
"sap",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@anthropic-ai/sdk": "^0.90.0",
|
|
55
|
+
"@azure/msal-node": "6.0.1",
|
|
55
56
|
"@cspeach/register-core": "0.4.0",
|
|
56
57
|
"@cspeach/sap-client": "0.1.18",
|
|
57
58
|
"@iarna/toml": "^2.2.5",
|