@cspeach/cli 1.1.32 → 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 +25 -8
- package/dist/commands/help.js +2 -1
- 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 +112 -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/month-usage.js +45 -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 +12 -1
- 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/slash-completer.js +1 -0
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +223 -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
|
@@ -20,6 +20,7 @@ import { promises as fs } from 'node:fs';
|
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { registerTool } from '../index.js';
|
|
22
22
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, isSeatReadOnlyFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
23
|
+
import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
|
|
23
24
|
import { isReadOnlyRel, isWorkRel, teamWorkRoot, teamWorkTarget } from '../_team-work-path.js';
|
|
24
25
|
const MAX_RESULTS = 500;
|
|
25
26
|
const MAX_FILES_TRAVERSED = 10_000;
|
|
@@ -120,6 +121,7 @@ export async function globHandler(args, ctx) {
|
|
|
120
121
|
const wantsCspeach = isCspeachPattern(args.pattern);
|
|
121
122
|
const collected = [];
|
|
122
123
|
let budget = MAX_FILES_TRAVERSED;
|
|
124
|
+
let credentialSkipped = 0;
|
|
123
125
|
async function walk(dir, relPrefix, denyRoot = root) {
|
|
124
126
|
if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
|
|
125
127
|
return;
|
|
@@ -162,6 +164,12 @@ export async function globHandler(args, ctx) {
|
|
|
162
164
|
const denylist = isDenylistedPath(abs, denyRoot);
|
|
163
165
|
if (denylist.blocked && !isReadOnlyProjectFile(abs, denyRoot))
|
|
164
166
|
continue;
|
|
167
|
+
// Security batch item 4, fix round 1 (F2) — credential files are not
|
|
168
|
+
// listed (same rule as the risk gate); one line says how many.
|
|
169
|
+
if (credentialFileRisk(ent.name)) {
|
|
170
|
+
credentialSkipped++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
165
173
|
let mtime = 0;
|
|
166
174
|
try {
|
|
167
175
|
const s = await fs.stat(abs);
|
|
@@ -221,17 +229,18 @@ export async function globHandler(args, ctx) {
|
|
|
221
229
|
// Sort by mtime descending, cap at MAX_RESULTS
|
|
222
230
|
collected.sort((a, b) => b.mtime - a.mtime);
|
|
223
231
|
const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
|
|
232
|
+
const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
|
|
224
233
|
if (sample.length === 0) {
|
|
225
234
|
// A search for saved results that did not name `.cspeach/` gets the hint.
|
|
226
235
|
const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
|
|
227
236
|
? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
|
|
228
237
|
: '';
|
|
229
|
-
return { content: `no files match "${args.pattern}"${hint}` };
|
|
238
|
+
return { content: `no files match "${args.pattern}"${hint}${skipNote}` };
|
|
230
239
|
}
|
|
231
240
|
const truncated = collected.length > MAX_RESULTS;
|
|
232
|
-
const trailer = truncated
|
|
241
|
+
const trailer = (truncated
|
|
233
242
|
? `\n... (${collected.length - MAX_RESULTS} more matches — narrow the pattern)`
|
|
234
|
-
: '';
|
|
243
|
+
: '') + skipNote;
|
|
235
244
|
return { content: sample.join('\n') + trailer };
|
|
236
245
|
}
|
|
237
246
|
registerTool({
|
|
@@ -23,6 +23,7 @@ import { registerTool } from '../index.js';
|
|
|
23
23
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
24
24
|
import { globToRegex } from './glob.js';
|
|
25
25
|
import { teamWorkTarget } from '../_team-work-path.js';
|
|
26
|
+
import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
|
|
26
27
|
const MAX_MATCHES = 250;
|
|
27
28
|
const MAX_FILES_TRAVERSED = 10_000;
|
|
28
29
|
const MAX_FILE_BYTES = 2_000_000;
|
|
@@ -69,6 +70,11 @@ export async function grepHandler(args, ctx) {
|
|
|
69
70
|
const matches = [];
|
|
70
71
|
const fileMatchCounts = new Map();
|
|
71
72
|
let budget = MAX_FILES_TRAVERSED;
|
|
73
|
+
// Security batch item 4, fix round 1 (F2) — credential files (.npmrc,
|
|
74
|
+
// .git-credentials, .env* outside the denylist, *.pem, *.key) are never
|
|
75
|
+
// searched: grep would return their text with no card. Counted, and one
|
|
76
|
+
// line says so. Same rule as the risk gate, so the lists cannot drift.
|
|
77
|
+
let credentialSkipped = 0;
|
|
72
78
|
async function walk(dir, relPrefix) {
|
|
73
79
|
if (budget <= 0 || matches.length >= MAX_MATCHES)
|
|
74
80
|
return;
|
|
@@ -106,6 +112,10 @@ export async function grepHandler(args, ctx) {
|
|
|
106
112
|
const denylist = isDenylistedPath(abs, root);
|
|
107
113
|
if (denylist.blocked)
|
|
108
114
|
continue;
|
|
115
|
+
if (credentialFileRisk(ent.name)) {
|
|
116
|
+
credentialSkipped++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
109
119
|
let stat;
|
|
110
120
|
try {
|
|
111
121
|
stat = await fs.stat(abs);
|
|
@@ -158,13 +168,14 @@ export async function grepHandler(args, ctx) {
|
|
|
158
168
|
}
|
|
159
169
|
}
|
|
160
170
|
await walk(realRoot, '');
|
|
171
|
+
const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
|
|
161
172
|
if (matches.length === 0) {
|
|
162
|
-
return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}` };
|
|
173
|
+
return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}${skipNote}` };
|
|
163
174
|
}
|
|
164
175
|
const truncated = matches.length >= MAX_MATCHES;
|
|
165
|
-
const trailer = truncated
|
|
176
|
+
const trailer = (truncated
|
|
166
177
|
? `\n... (cap of ${MAX_MATCHES} matches reached — narrow the pattern or use a glob filter)`
|
|
167
|
-
: '';
|
|
178
|
+
: '') + skipNote;
|
|
168
179
|
if (outputMode === 'files_with_matches') {
|
|
169
180
|
const files = [...new Set(matches.map((m) => m.file))];
|
|
170
181
|
return { content: files.join('\n') + trailer };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toAnthropicTools } from './index.js';
|
|
2
|
+
import { profileForMode } from '../agent/connection-profile.js';
|
|
2
3
|
/** The server-side tool search tool (probe P6: this exact name). */
|
|
3
4
|
export const TOOL_SEARCH_TOOL = Object.freeze({
|
|
4
5
|
type: 'tool_search_tool_regex_20251119',
|
|
@@ -105,25 +106,23 @@ export function modelAcceptsDeferredTools(model) {
|
|
|
105
106
|
return DEFERRED_TOOL_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
|
|
106
107
|
}
|
|
107
108
|
/**
|
|
108
|
-
* Mode precedence (ruling F13)
|
|
109
|
-
*
|
|
110
|
-
* (`deferred` | `static`)
|
|
111
|
-
* '
|
|
112
|
-
*
|
|
109
|
+
* Mode precedence (ruling F13), read from the connection profile since the
|
|
110
|
+
* Azure tenant mode (Task 1): 'static' profiles (AI-hub, local, azure v0) are
|
|
111
|
+
* always 'static'; otherwise env `CSPEACH_TOOL_LOADING` (`deferred` | `static`)
|
|
112
|
+
* wins; then 'served' profiles (managed, BYOK) use the served value (default
|
|
113
|
+
* 'static'); an unknown/fake mode ('env-only') stays 'static'.
|
|
113
114
|
*/
|
|
114
115
|
export function resolveToolLoading(i) {
|
|
115
|
-
|
|
116
|
+
const rule = (i.profile ?? profileForMode(i.providerMode)).deferredTools;
|
|
117
|
+
if (rule === 'static')
|
|
116
118
|
return 'static';
|
|
117
119
|
const raw = i.env.CSPEACH_TOOL_LOADING?.trim().toLowerCase();
|
|
118
120
|
if (raw === 'deferred' || raw === 'static')
|
|
119
121
|
return raw;
|
|
120
122
|
// Task 21 fix round (controller ruling): an old proxy serves no tool_loading
|
|
121
123
|
// and adds none of the deferred-tool betas, so 'deferred' only when served.
|
|
122
|
-
if (
|
|
124
|
+
if (rule === 'served')
|
|
123
125
|
return i.served ?? 'static';
|
|
124
|
-
if (i.providerMode === 'byok')
|
|
125
|
-
return i.served ?? 'static';
|
|
126
|
-
// Unknown/fake provider modes keep today's full list.
|
|
127
126
|
return 'static';
|
|
128
127
|
}
|
|
129
128
|
/**
|
|
@@ -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
|
+
}
|