dsh-llm-verifier 0.6.2 → 0.6.4
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/README.md +8 -3
- package/lib/client.js +49 -23
- package/lib/client.js.map +1 -1
- package/lib/index.js +334 -10
- package/lib/index.js.map +1 -1
- package/lib/types/client-fields.d.ts +4 -0
- package/lib/types/client-fields.d.ts.map +1 -1
- package/lib/types/client-fields.js +21 -4
- package/lib/types/client-fields.js.map +1 -1
- package/lib/types/client-i18n.d.ts +24 -0
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +16 -0
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +9 -8
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +12 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +13 -1
- package/lib/types/config.js.map +1 -1
- package/lib/types/engine.d.ts +3 -0
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +7 -1
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +45 -6
- package/lib/types/index.js.map +1 -1
- package/lib/types/pricing.d.ts +132 -0
- package/lib/types/pricing.d.ts.map +1 -0
- package/lib/types/pricing.js +279 -0
- package/lib/types/pricing.js.map +1 -0
- package/package.json +1 -1
- package/scripts/sync-installed-profiles.mjs +226 -21
- package/src/client-fields.test.ts +2 -0
- package/src/client-fields.ts +27 -4
- package/src/client-i18n.ts +16 -0
- package/src/client.test.ts +59 -0
- package/src/client.tsx +11 -12
- package/src/config.ts +25 -1
- package/src/engine.ts +8 -2
- package/src/index.ts +45 -6
- package/src/pricing.test.ts +174 -0
- package/src/pricing.ts +324 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic USD pricing for one judge route.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists: the harness ships no price to plugins. `@deepseek-ai/dsh-llm`
|
|
5
|
+
* exposes usage tokens and `resolveModel` metadata, never money, and `llm-pi-ai`
|
|
6
|
+
* deliberately zeroes pi-ai's own `cost` metadata ("The harness never reads pi-ai's cost
|
|
7
|
+
* metadata … no consumer reports spend"). Before this module the operator had to type two
|
|
8
|
+
* USD-per-million rates by hand and every statistics row stayed $0 until they did.
|
|
9
|
+
*
|
|
10
|
+
* Four sources, in order, each best-effort and independently degradable:
|
|
11
|
+
* 1. the operator's explicit rates (`estimated*UsdPerMillion`) — they always win;
|
|
12
|
+
* 2. the installed pi-ai provider catalog on disk (`@earendil-works/pi-ai/dist/providers/data`),
|
|
13
|
+
* the same models.dev snapshot the host itself carries, cached in-process;
|
|
14
|
+
* 3. models.dev over HTTPS, cached for the same TTL, consulted only when 2 misses;
|
|
15
|
+
* 4. `none` — zero, which the statistics UI treats as "not priced" rather than as "free".
|
|
16
|
+
*
|
|
17
|
+
* A route may be a reseller whose provider id is in neither table (the bundled catalog does
|
|
18
|
+
* not ship `command-code`, and models.dev has no such provider). The optional
|
|
19
|
+
* `priceProviderOverride` makes that explicit: it names the provider whose list price the
|
|
20
|
+
* operator chose to follow. Nothing is ever guessed across providers — the same model id is
|
|
21
|
+
* listed at 0.10/0.40 by one vendor and 0.65/1.45 by another, so a silent cross-provider
|
|
22
|
+
* guess would put a fabricated number in a cost column.
|
|
23
|
+
*
|
|
24
|
+
* Every failure (package not installed, unreadable file, network error, unknown shape)
|
|
25
|
+
* degrades to the next source and finally to `none`: this module never throws, so automatic
|
|
26
|
+
* pricing can never block or fail a verification.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-llm-verifier/pricing
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
31
|
+
import { dirname, join } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
/** Rates for a route nothing could price. */
|
|
34
|
+
export const UNPRICED = { input: 0, output: 0, cachedInput: 0, source: 'none' };
|
|
35
|
+
/**
|
|
36
|
+
* USD cost of one usage figure under a rate table.
|
|
37
|
+
*
|
|
38
|
+
* Cached prompt tokens are billed at their own rate instead of the full input rate. The old
|
|
39
|
+
* formula added them to `inputTokens` and multiplied by the input price, which over-reported
|
|
40
|
+
* every cache-heavy call by the cache-read discount — on a real acceptance run
|
|
41
|
+
* (2,374 uncached + 134,912 cached input tokens) that is the difference between ~$0.0004 and
|
|
42
|
+
* an order of magnitude more.
|
|
43
|
+
* @param usage - token counts of the call.
|
|
44
|
+
* @param prices - rates in effect for the route that produced them.
|
|
45
|
+
* @returns The estimated cost in USD.
|
|
46
|
+
*/
|
|
47
|
+
export function costUsd(usage, prices) {
|
|
48
|
+
return (usage.inputTokens * prices.input + usage.cachedInputTokens * prices.cachedInput + usage.outputTokens * prices.output) / 1_000_000;
|
|
49
|
+
}
|
|
50
|
+
/** Identity of one route inside a {@link PriceTable}. */
|
|
51
|
+
export function priceKey(provider, model) {
|
|
52
|
+
return provider + '\u0000' + model;
|
|
53
|
+
}
|
|
54
|
+
function object(value) {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
function rate(value) {
|
|
58
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** Reads one `{input, output, cacheRead|cache_read}` record; `undefined` when nothing usable is there. */
|
|
61
|
+
export function priceEntry(value) {
|
|
62
|
+
const row = object(value);
|
|
63
|
+
if (row === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
const input = rate(row.input);
|
|
66
|
+
const output = rate(row.output);
|
|
67
|
+
if (input === undefined || output === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
return { input, output, cachedInput: rate(row.cacheRead) ?? rate(row.cache_read) ?? input };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Merge one provider document into a table.
|
|
73
|
+
*
|
|
74
|
+
* Accepts both shapes this module reads: the pi-ai catalog (`{ api: { modelId: { cost } } }`,
|
|
75
|
+
* `cost` nested) and models.dev (`{ models: { modelId: { cost } } }`, model keys at the top of
|
|
76
|
+
* the provider object). Unrecognized entries are skipped, never fatal.
|
|
77
|
+
* @param provider - provider id the models belong to.
|
|
78
|
+
* @param document - parsed provider document.
|
|
79
|
+
* @param into - table to merge into.
|
|
80
|
+
* @returns How many models were added.
|
|
81
|
+
*/
|
|
82
|
+
export function mergeProviderDocument(provider, document, into) {
|
|
83
|
+
const root = object(document);
|
|
84
|
+
if (root === undefined)
|
|
85
|
+
return 0;
|
|
86
|
+
const models = {};
|
|
87
|
+
// pi-ai: one key per wire protocol, each holding { modelId: model }.
|
|
88
|
+
for (const [key, value] of Object.entries(root)) {
|
|
89
|
+
if (key === 'models')
|
|
90
|
+
continue;
|
|
91
|
+
const block = object(value);
|
|
92
|
+
if (block === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
for (const [id, model] of Object.entries(block))
|
|
95
|
+
models[id] = model;
|
|
96
|
+
}
|
|
97
|
+
// models.dev: a single { models: { modelId: model } } block.
|
|
98
|
+
for (const [id, model] of Object.entries(object(root.models) ?? {}))
|
|
99
|
+
models[id] = model;
|
|
100
|
+
let added = 0;
|
|
101
|
+
for (const [id, model] of Object.entries(models)) {
|
|
102
|
+
const row = object(model);
|
|
103
|
+
if (row === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
const entry = priceEntry(row.cost);
|
|
106
|
+
if (entry === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
into.set(priceKey(provider, id), entry);
|
|
109
|
+
added += 1;
|
|
110
|
+
}
|
|
111
|
+
return added;
|
|
112
|
+
}
|
|
113
|
+
/** Parses one models.dev snapshot (`{ provider: { models } }`) into a table. */
|
|
114
|
+
export function parseModelsDevDocument(document, into) {
|
|
115
|
+
let added = 0;
|
|
116
|
+
for (const [provider, value] of Object.entries(object(document) ?? {}))
|
|
117
|
+
added += mergeProviderDocument(provider, value, into);
|
|
118
|
+
return added;
|
|
119
|
+
}
|
|
120
|
+
/** The catalog inside one package root, if the adapter is installed there. */
|
|
121
|
+
function catalogUnder(root) {
|
|
122
|
+
const candidate = join(root, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data');
|
|
123
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Locate the installed pi-ai catalog directory.
|
|
127
|
+
*
|
|
128
|
+
* The package's `exports` map publishes no subpath, so a module resolver cannot even name its
|
|
129
|
+
* files and this walks the filesystem instead: upward from this module (the plugin normally
|
|
130
|
+
* sits in the same `profiles/<name>/node_modules` tree as the adapter), then the host profile
|
|
131
|
+
* directories for a plugin loaded straight from a checkout. `DSH_VERIFIER_PI_AI_DATA`
|
|
132
|
+
* overrides both for a deployment whose layout differs.
|
|
133
|
+
*
|
|
134
|
+
* Returns `undefined` when the package is not installed — the caller then simply has one
|
|
135
|
+
* source fewer, which is the documented degradation and not an error.
|
|
136
|
+
* @param from - directory to start the upward walk from; defaults to this module's directory.
|
|
137
|
+
* @param home - DSH home whose `profiles` are searched last; defaults to `$DSH_HOME`.
|
|
138
|
+
* @returns Absolute `dist/providers/data` directory, or `undefined`.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveCatalogDataDir(from = dirname(fileURLToPath(import.meta.url)), home = process.env.DSH_HOME?.trim()) {
|
|
141
|
+
const override = process.env.DSH_VERIFIER_PI_AI_DATA?.trim();
|
|
142
|
+
if (override)
|
|
143
|
+
return existsSync(override) ? override : undefined;
|
|
144
|
+
let dir = from;
|
|
145
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
146
|
+
const found = catalogUnder(dir);
|
|
147
|
+
if (found !== undefined)
|
|
148
|
+
return found;
|
|
149
|
+
const parent = dirname(dir);
|
|
150
|
+
if (parent === dir)
|
|
151
|
+
break;
|
|
152
|
+
dir = parent;
|
|
153
|
+
}
|
|
154
|
+
if (!home)
|
|
155
|
+
return undefined;
|
|
156
|
+
const profiles = join(home, 'profiles');
|
|
157
|
+
const direct = catalogUnder(profiles);
|
|
158
|
+
if (direct !== undefined)
|
|
159
|
+
return direct;
|
|
160
|
+
try {
|
|
161
|
+
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
162
|
+
if (!entry.isDirectory())
|
|
163
|
+
continue;
|
|
164
|
+
const found = catalogUnder(join(profiles, entry.name));
|
|
165
|
+
if (found !== undefined)
|
|
166
|
+
return found;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch { /* no profiles directory: nothing left to find */ }
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
/** Reads every `*.json` provider document in a catalog directory into a table. */
|
|
173
|
+
export function loadCatalogTable(dataDir) {
|
|
174
|
+
const table = new Map();
|
|
175
|
+
let files;
|
|
176
|
+
try {
|
|
177
|
+
files = readdirSync(dataDir).filter(name => name.endsWith('.json'));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return table;
|
|
181
|
+
}
|
|
182
|
+
for (const file of files) {
|
|
183
|
+
try {
|
|
184
|
+
mergeProviderDocument(file.slice(0, -'.json'.length), JSON.parse(readFileSync(join(dataDir, file), 'utf8')), table);
|
|
185
|
+
}
|
|
186
|
+
catch { /* one unreadable provider must not lose the other 39 */ }
|
|
187
|
+
}
|
|
188
|
+
return table;
|
|
189
|
+
}
|
|
190
|
+
/** Default lifetime of a loaded price table, matching the capability memory's TTL. */
|
|
191
|
+
export const PRICE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
192
|
+
/** Public models.dev snapshot; the upstream of the pi-ai catalog the host ships. */
|
|
193
|
+
export const MODELS_DEV_URL = 'https://models.dev/api.json';
|
|
194
|
+
/**
|
|
195
|
+
* Resolves a route's rates once per TTL, caching both tables in-process.
|
|
196
|
+
*
|
|
197
|
+
* The two tables are independent: a broken catalog read still leaves the online lookup, and
|
|
198
|
+
* a network failure still leaves the catalog. Both are single-flighted so concurrent topics
|
|
199
|
+
* cannot each fetch the 4.6 MB models.dev snapshot.
|
|
200
|
+
*/
|
|
201
|
+
export class PriceResolver {
|
|
202
|
+
deps;
|
|
203
|
+
catalog;
|
|
204
|
+
online;
|
|
205
|
+
onlineFlight;
|
|
206
|
+
constructor(deps = {}) {
|
|
207
|
+
this.deps = deps;
|
|
208
|
+
}
|
|
209
|
+
/** The installed catalog table, loaded at most once per TTL. */
|
|
210
|
+
catalogTable() {
|
|
211
|
+
const now = (this.deps.now ?? Date.now)();
|
|
212
|
+
const ttl = this.deps.ttlMs ?? PRICE_TTL_MS;
|
|
213
|
+
if (this.catalog !== undefined && now - this.catalog.at <= ttl)
|
|
214
|
+
return this.catalog.table;
|
|
215
|
+
const dir = (this.deps.catalogDir ?? (() => resolveCatalogDataDir()))();
|
|
216
|
+
const table = dir === undefined ? new Map() : loadCatalogTable(dir);
|
|
217
|
+
this.catalog = { at: now, table };
|
|
218
|
+
return table;
|
|
219
|
+
}
|
|
220
|
+
/** The models.dev table, fetched at most once per TTL. Never rejects. */
|
|
221
|
+
async onlineTable() {
|
|
222
|
+
const now = (this.deps.now ?? Date.now)();
|
|
223
|
+
const ttl = this.deps.ttlMs ?? PRICE_TTL_MS;
|
|
224
|
+
if (this.online !== undefined && now - this.online.at <= ttl)
|
|
225
|
+
return this.online.table;
|
|
226
|
+
this.onlineFlight ??= (async () => {
|
|
227
|
+
const table = new Map();
|
|
228
|
+
try {
|
|
229
|
+
const fetchImpl = this.deps.fetch ?? fetch;
|
|
230
|
+
const response = await fetchImpl(this.deps.url ?? MODELS_DEV_URL, { signal: AbortSignal.timeout(this.deps.timeoutMs ?? 20_000) });
|
|
231
|
+
if (response.ok)
|
|
232
|
+
parseModelsDevDocument(JSON.parse(await response.text()), table);
|
|
233
|
+
}
|
|
234
|
+
catch { /* offline, blocked, or a changed shape: no price beats a fabricated one */ }
|
|
235
|
+
this.online = { at: (this.deps.now ?? Date.now)(), table };
|
|
236
|
+
this.onlineFlight = undefined;
|
|
237
|
+
return table;
|
|
238
|
+
})();
|
|
239
|
+
return this.onlineFlight;
|
|
240
|
+
}
|
|
241
|
+
fromTable(table, providers, model) {
|
|
242
|
+
for (const provider of providers) {
|
|
243
|
+
const entry = table.get(priceKey(provider, model));
|
|
244
|
+
if (entry !== undefined)
|
|
245
|
+
return entry;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Rates in effect for one route.
|
|
251
|
+
* @param provider - route's provider id.
|
|
252
|
+
* @param model - route's model id.
|
|
253
|
+
* @param options - manual rates plus which automatic sources may run.
|
|
254
|
+
* @returns The rates, always, with the source that answered.
|
|
255
|
+
*/
|
|
256
|
+
async resolve(provider, model, options) {
|
|
257
|
+
const manual = options.manual;
|
|
258
|
+
if (manual.input > 0 || manual.output > 0) {
|
|
259
|
+
// A typed rate is a fact about the deployment. Cache reads keep the operator's own
|
|
260
|
+
// cached rate when they gave one; otherwise they fall back to the input rate, which is
|
|
261
|
+
// exactly the pre-existing estimate rather than silently inventing a discount.
|
|
262
|
+
return { input: manual.input, output: manual.output, cachedInput: manual.cachedInput > 0 ? manual.cachedInput : manual.input, source: 'manual' };
|
|
263
|
+
}
|
|
264
|
+
const override = options.overrideProvider?.trim();
|
|
265
|
+
const candidates = override && override !== provider ? [override, provider] : [provider];
|
|
266
|
+
if (options.fromCatalog !== false) {
|
|
267
|
+
const entry = this.fromTable(this.catalogTable(), candidates, model);
|
|
268
|
+
if (entry !== undefined)
|
|
269
|
+
return { ...entry, source: 'catalog' };
|
|
270
|
+
}
|
|
271
|
+
if (options.online === true) {
|
|
272
|
+
const entry = this.fromTable(await this.onlineTable(), candidates, model);
|
|
273
|
+
if (entry !== undefined)
|
|
274
|
+
return { ...entry, source: 'online' };
|
|
275
|
+
}
|
|
276
|
+
return UNPRICED;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=pricing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pricing.js","sourceRoot":"","sources":["../../src/pricing.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -6,7 +6,18 @@
|
|
|
6
6
|
* they were hard-linked at install time and pnpm considers the dependency unchanged, so it will not
|
|
7
7
|
* re-link them (verified: a plain install, --force and a re-add all leave the old inode in place).
|
|
8
8
|
* Only removing the installed directory and re-running `pnpm install` re-creates the links. That is
|
|
9
|
-
* what this does, for every profile whose package.json
|
|
9
|
+
* what this does, for every profile whose package.json owns a copy of this package.
|
|
10
|
+
*
|
|
11
|
+
* A profile that only lists this package in `dsh.profile.bundles` (its dependencies stay empty)
|
|
12
|
+
* owns no copy: the harness resolves it through the shared `$DSH_HOME/profiles/node_modules`
|
|
13
|
+
* fallback, which links the bundle's real directory, so there is nothing to refresh there.
|
|
14
|
+
*
|
|
15
|
+
* Refreshing is destructive (pnpm re-links a `file:` directory dependency only once its entry is
|
|
16
|
+
* gone), so the current copy is moved aside first and restored whenever the install fails: a stale
|
|
17
|
+
* copy is far better than a missing plugin. Failures print the tail of pnpm's own log instead of a
|
|
18
|
+
* bare "Command failed", and a `file:` pin left behind by a moved repository is repaired in place,
|
|
19
|
+
* because that state makes every install variant fail with ENOENT before anything needs to be
|
|
20
|
+
* removed (verified: `--force`, `--fix-lockfile`, `pnpm add` and a plain install all reuse the pin).
|
|
10
21
|
*
|
|
11
22
|
* With no matching profile (CI, a fresh clone, another machine) it is a no-op and exits 0, so it can
|
|
12
23
|
* sit at the end of the build without affecting the release gate.
|
|
@@ -14,9 +25,9 @@
|
|
|
14
25
|
* Usage: node scripts/sync-installed-profiles.mjs [--check] [--home <DSH_HOME>]
|
|
15
26
|
*/
|
|
16
27
|
import { execFileSync } from 'node:child_process'
|
|
17
|
-
import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
18
|
-
import { join, resolve } from 'node:path'
|
|
19
|
-
import { homedir } from 'node:os'
|
|
28
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
29
|
+
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
30
|
+
import { homedir, tmpdir } from 'node:os'
|
|
20
31
|
|
|
21
32
|
const repo = resolve(import.meta.dirname, '..')
|
|
22
33
|
const argv = process.argv.slice(2)
|
|
@@ -24,27 +35,186 @@ const check = argv.includes('--check')
|
|
|
24
35
|
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined }
|
|
25
36
|
const home = resolve(flag('--home') ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
|
|
26
37
|
const profilesDir = join(home, 'profiles')
|
|
38
|
+
const sharedDir = join(profilesDir, 'node_modules')
|
|
27
39
|
const name = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).name
|
|
28
40
|
|
|
29
|
-
/** Profiles that declare this package as a dependency or as a bundle. */
|
|
30
|
-
function
|
|
41
|
+
/** Profiles that declare this package as a dependency or as a bundle, with the declared specifier. */
|
|
42
|
+
function declaredProfiles() {
|
|
31
43
|
const found = []
|
|
32
44
|
let entries
|
|
33
45
|
try { entries = readdirSync(profilesDir, { withFileTypes: true }) } catch { return found }
|
|
34
46
|
for (const entry of entries) {
|
|
35
47
|
if (!entry.isDirectory() || entry.name === 'node_modules') continue
|
|
36
|
-
const
|
|
48
|
+
const dir = join(profilesDir, entry.name)
|
|
49
|
+
const manifest = join(dir, 'package.json')
|
|
37
50
|
if (!existsSync(manifest)) continue
|
|
38
51
|
try {
|
|
39
52
|
const pkg = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
40
|
-
const
|
|
53
|
+
const dependency = pkg.dependencies?.[name]
|
|
41
54
|
const asBundle = Array.isArray(pkg.dsh?.profile?.bundles) && pkg.dsh.profile.bundles.includes(name)
|
|
42
|
-
if (
|
|
55
|
+
if (dependency !== undefined || asBundle) found.push({ dir, dependency })
|
|
43
56
|
} catch { /* an unreadable manifest is not fatal */ }
|
|
44
57
|
}
|
|
45
58
|
return found
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
/** Absolute directory a `file:`/`link:` dependency points at, or undefined for other specifiers. */
|
|
62
|
+
function localDependencyTarget(dir, specifier) {
|
|
63
|
+
if (typeof specifier !== 'string') return undefined
|
|
64
|
+
const match = /^(?:file|link):(.*)$/i.exec(specifier.trim())
|
|
65
|
+
const target = match === null ? '' : match[1].trim()
|
|
66
|
+
return target === '' ? undefined : resolve(dir, target)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drop one layer of surrounding quotes from a YAML scalar. */
|
|
70
|
+
function unquote(value) {
|
|
71
|
+
const trimmed = value.trim()
|
|
72
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed)
|
|
73
|
+
return quoted === null ? trimmed : quoted[2]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `file:` directory pnpm-lock.yaml pinned for this package, or undefined when it pins none.
|
|
78
|
+
*
|
|
79
|
+
* pnpm resolves a `file:` directory dependency from this pin, not from package.json, which is why a
|
|
80
|
+
* moved repository keeps installing from the old location.
|
|
81
|
+
*/
|
|
82
|
+
function lockedPin(dir) {
|
|
83
|
+
const lockfile = join(dir, 'pnpm-lock.yaml')
|
|
84
|
+
if (!existsSync(lockfile)) return undefined
|
|
85
|
+
const lines = readFileSync(lockfile, 'utf8').split(/\r?\n/)
|
|
86
|
+
const key = new RegExp('^ ' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '@(?:file|link):(.+):$')
|
|
87
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
88
|
+
const match = key.exec(lines[i])
|
|
89
|
+
if (match === null) continue
|
|
90
|
+
const resolution = /^\s+resolution:\s*\{\s*directory:\s*([^,}]+?)\s*[,}]/.exec(lines[i + 1] ?? '')
|
|
91
|
+
const pinned = unquote(resolution === null ? match[1] : resolution[1])
|
|
92
|
+
return { lockfile, pinned, dir: resolve(dir, pinned) }
|
|
93
|
+
}
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A pin that is already broken, with the path the manifest declares instead, or undefined. */
|
|
98
|
+
function stalePin(dir, declared) {
|
|
99
|
+
if (declared === undefined) return undefined
|
|
100
|
+
const pin = lockedPin(dir)
|
|
101
|
+
if (pin === undefined || existsSync(pin.dir)) return undefined
|
|
102
|
+
const replacement = relative(dir, declared).split(sep).join('/')
|
|
103
|
+
return replacement === pin.pinned ? undefined : { ...pin, replacement }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rewrite a stale pin to the directory the manifest declares.
|
|
108
|
+
*
|
|
109
|
+
* Only the pin is rewritten: re-resolving the whole lockfile would silently move every other
|
|
110
|
+
* dependency of the profile to a newer version.
|
|
111
|
+
*/
|
|
112
|
+
function repairStalePin(dir, declared) {
|
|
113
|
+
const stale = stalePin(dir, declared)
|
|
114
|
+
if (stale === undefined) return
|
|
115
|
+
const content = readFileSync(stale.lockfile, 'utf8')
|
|
116
|
+
const repaired = content.split(stale.pinned).join(stale.replacement)
|
|
117
|
+
if (repaired === content) return
|
|
118
|
+
writeFileSync(stale.lockfile, repaired)
|
|
119
|
+
console.log('sync-installed-profiles: repaired the stale ' + basename(stale.lockfile) + ' pin in ' + dir + ' (' + stale.pinned + ' -> ' + stale.replacement + ')')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The specifier this profile should declare instead, when its `file:` dependency points at a
|
|
124
|
+
* repository that is no longer there.
|
|
125
|
+
*
|
|
126
|
+
* The declared path is absolute because pnpm materializes a directory dependency that way; nothing
|
|
127
|
+
* in package.json can express "this package, wherever it happens to live". The rewrite is therefore
|
|
128
|
+
* done here, by the build of the package that is being moved, which is the only party that knows
|
|
129
|
+
* both locations at once.
|
|
130
|
+
*/
|
|
131
|
+
function repointableSpecifier(profile) {
|
|
132
|
+
if (typeof profile.dependency !== 'string') return undefined
|
|
133
|
+
const match = /^\s*(file|link):/i.exec(profile.dependency)
|
|
134
|
+
if (match === null) return undefined
|
|
135
|
+
const declared = localDependencyTarget(profile.dir, profile.dependency)
|
|
136
|
+
if (declared === undefined || existsSync(declared)) return undefined
|
|
137
|
+
return { protocol: match[1].toLowerCase(), specifier: match[1].toLowerCase() + ':' + repo.split(sep).join('/'), declared }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Point a profile whose dependency still names an earlier checkout at this repository.
|
|
142
|
+
*
|
|
143
|
+
* Both places that carry the path are rewritten: package.json, and the lockfile line that mirrors
|
|
144
|
+
* its specifier verbatim (pnpm keeps resolving a `file:` directory from the lockfile, so fixing
|
|
145
|
+
* only the manifest would leave the install failing exactly as before).
|
|
146
|
+
*/
|
|
147
|
+
function repointProfile(profile, target) {
|
|
148
|
+
const manifest = join(profile.dir, 'package.json')
|
|
149
|
+
const text = readFileSync(manifest, 'utf8')
|
|
150
|
+
const repointed = text.split(profile.dependency).join(target.specifier)
|
|
151
|
+
if (repointed === text) return undefined
|
|
152
|
+
writeFileSync(manifest, repointed)
|
|
153
|
+
const lockfile = join(profile.dir, 'pnpm-lock.yaml')
|
|
154
|
+
if (existsSync(lockfile)) {
|
|
155
|
+
const before = readFileSync(lockfile, 'utf8')
|
|
156
|
+
const after = before.split(profile.dependency.slice(profile.dependency.indexOf(':') + 1))
|
|
157
|
+
.join(target.specifier.slice(target.specifier.indexOf(':') + 1))
|
|
158
|
+
if (after !== before) writeFileSync(lockfile, after)
|
|
159
|
+
}
|
|
160
|
+
console.log('sync-installed-profiles: repointed ' + manifest + ' at ' + target.specifier)
|
|
161
|
+
return target.specifier
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Last lines of pnpm's own log, so a failed install reports a cause instead of "Command failed". */
|
|
165
|
+
function installLogTail(log) {
|
|
166
|
+
try {
|
|
167
|
+
const lines = readFileSync(log, 'utf8').trim().split(/\r?\n/).filter(line => line.trim() !== '')
|
|
168
|
+
return lines.length === 0 ? undefined : lines.slice(-8).join(' / ')
|
|
169
|
+
} catch { return undefined }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run `pnpm install` in one profile.
|
|
174
|
+
*
|
|
175
|
+
* pnpm's output goes to a log file rather than a pipe: the harness sandbox refuses piped stdio, and
|
|
176
|
+
* the log is what turns a bare execFileSync error into an actionable message.
|
|
177
|
+
*/
|
|
178
|
+
function installProfile(dir) {
|
|
179
|
+
const log = join(tmpdir(), 'dsh-sync-installed-profiles-' + process.pid + '.log')
|
|
180
|
+
let fd
|
|
181
|
+
try { fd = openSync(log, 'w') } catch { fd = undefined }
|
|
182
|
+
const stdio = fd === undefined ? 'ignore' : ['ignore', fd, fd]
|
|
183
|
+
try {
|
|
184
|
+
if (process.platform === 'win32') execFileSync('cmd', ['/c', 'pnpm install'], { cwd: dir, stdio })
|
|
185
|
+
else execFileSync('pnpm', ['install'], { cwd: dir, stdio })
|
|
186
|
+
return { ok: true }
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return { ok: false, detail: installLogTail(log) ?? (error instanceof Error ? error.message : String(error)) }
|
|
189
|
+
} finally {
|
|
190
|
+
if (fd !== undefined) closeSync(fd)
|
|
191
|
+
try { rmSync(log, { force: true }) } catch { /* a leftover log is harmless */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Move the installed copy aside, reinstall it, and restore the previous copy when that fails. */
|
|
196
|
+
function refreshInstalled(dir) {
|
|
197
|
+
const installed = join(dir, 'node_modules', name)
|
|
198
|
+
const backup = installed + '.sync-backup'
|
|
199
|
+
rmSync(backup, { recursive: true, force: true })
|
|
200
|
+
renameSync(installed, backup)
|
|
201
|
+
const result = installProfile(dir)
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
rmSync(installed, { recursive: true, force: true })
|
|
204
|
+
renameSync(backup, installed)
|
|
205
|
+
return result
|
|
206
|
+
}
|
|
207
|
+
// pnpm exits 0 after pruning a copy the profile no longer declares; keeping the old copy is right
|
|
208
|
+
// there too, because the plugin disappearing from the profile is exactly what this must prevent.
|
|
209
|
+
if (!existsSync(installed)) {
|
|
210
|
+
renameSync(backup, installed)
|
|
211
|
+
return { ok: false, detail: 'pnpm install completed without creating the package directory (is it still a dependency of that profile?)' }
|
|
212
|
+
}
|
|
213
|
+
try { rmSync(backup, { recursive: true, force: true }) } catch { /* a stale .sync-backup is inert */ }
|
|
214
|
+
breakHardlinks(installed)
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
48
218
|
/**
|
|
49
219
|
* Break hard links in a directory or file by rewriting any file that has nlink > 1.
|
|
50
220
|
*
|
|
@@ -78,7 +248,7 @@ function ensureRepoHasNoHardlinks() {
|
|
|
78
248
|
}
|
|
79
249
|
}
|
|
80
250
|
|
|
81
|
-
const profiles =
|
|
251
|
+
const profiles = declaredProfiles()
|
|
82
252
|
if (profiles.length === 0) {
|
|
83
253
|
ensureRepoHasNoHardlinks()
|
|
84
254
|
console.log('sync-installed-profiles: no profile installs ' + name + ' (nothing to refresh)')
|
|
@@ -86,28 +256,63 @@ if (profiles.length === 0) {
|
|
|
86
256
|
}
|
|
87
257
|
|
|
88
258
|
let refreshed = 0
|
|
259
|
+
let installedProfiles = 0
|
|
89
260
|
for (const profile of profiles) {
|
|
90
|
-
const installed = join(profile, 'node_modules', name)
|
|
261
|
+
const installed = join(profile.dir, 'node_modules', name)
|
|
91
262
|
if (!existsSync(installed)) {
|
|
92
|
-
|
|
263
|
+
const shared = join(sharedDir, name)
|
|
264
|
+
// A bundle-only profile is served by the harness' shared fallback (a link to the bundle's real
|
|
265
|
+
// directory), so it owns no copy to keep in sync; pointing it at `pnpm install` would be wrong
|
|
266
|
+
// advice, since its dependencies are deliberately empty.
|
|
267
|
+
if (profile.dependency === undefined && existsSync(shared)) {
|
|
268
|
+
console.log('sync-installed-profiles: ' + profile.dir + ' declares ' + name + ' as a bundle only; it resolves through ' + shared + ' (nothing to refresh)')
|
|
269
|
+
} else {
|
|
270
|
+
console.log('sync-installed-profiles: ' + profile.dir + ' declares ' + name + ' but has no installed copy; run pnpm install there')
|
|
271
|
+
}
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
installedProfiles += 1
|
|
275
|
+
// A profile that still names an earlier checkout is repointed before anything is removed, so a
|
|
276
|
+
// moved repository refreshes instead of leaving the profile on a stale copy forever.
|
|
277
|
+
let declared = localDependencyTarget(profile.dir, profile.dependency)
|
|
278
|
+
const repoint = repointableSpecifier(profile)
|
|
279
|
+
if (repoint !== undefined) {
|
|
280
|
+
const applied = check ? repoint.specifier : repointProfile(profile, repoint)
|
|
281
|
+
if (applied === undefined) {
|
|
282
|
+
console.log('! sync-installed-profiles: ' + profile.dir + ' depends on ' + repoint.declared + ', which does not exist and could not be rewritten; kept the installed copy untouched')
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
if (check) console.log('sync-installed-profiles: would repoint ' + join(profile.dir, 'package.json') + ' at ' + applied)
|
|
286
|
+
declared = repo
|
|
287
|
+
}
|
|
288
|
+
if (declared !== undefined && !existsSync(declared)) {
|
|
289
|
+
console.log('! sync-installed-profiles: ' + profile.dir + ' depends on ' + declared + ', which does not exist; kept the installed copy untouched')
|
|
93
290
|
continue
|
|
94
291
|
}
|
|
95
292
|
if (check) {
|
|
293
|
+
const stale = stalePin(profile.dir, declared)
|
|
294
|
+
if (stale !== undefined) console.log('sync-installed-profiles: would repair the stale ' + basename(stale.lockfile) + ' pin in ' + profile.dir + ' (' + stale.pinned + ' -> ' + stale.replacement + ')')
|
|
96
295
|
console.log('sync-installed-profiles: would refresh ' + installed)
|
|
97
296
|
continue
|
|
98
297
|
}
|
|
99
298
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
299
|
+
repairStalePin(profile.dir, declared)
|
|
300
|
+
const result = refreshInstalled(profile.dir)
|
|
301
|
+
if (!result.ok) {
|
|
302
|
+
console.log('! sync-installed-profiles: could not refresh ' + installed + ' — ' + result.detail + ' (kept the previous copy)')
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
104
305
|
refreshed += 1
|
|
105
|
-
|
|
306
|
+
const entry = join(installed, 'lib', 'index.js')
|
|
307
|
+
console.log('sync-installed-profiles: refreshed ' + installed + (existsSync(entry) ? '' : ' (warning: ' + entry + ' is missing)'))
|
|
106
308
|
} catch (error) {
|
|
107
|
-
// The
|
|
108
|
-
|
|
309
|
+
// The install never ran, or the copy could not be moved aside: put it back and report.
|
|
310
|
+
const backup = installed + '.sync-backup'
|
|
311
|
+
try { if (!existsSync(installed) && existsSync(backup)) renameSync(backup, installed) } catch { /* nothing else can be done here */ }
|
|
312
|
+
console.log('! sync-installed-profiles: could not refresh ' + installed + ' — ' + (error instanceof Error ? error.message : String(error)) + ' (kept the previous copy)')
|
|
109
313
|
}
|
|
110
314
|
}
|
|
111
315
|
ensureRepoHasNoHardlinks()
|
|
112
|
-
|
|
113
|
-
|
|
316
|
+
// The denominator counts profiles that really own a copy: bundle-only profiles have nothing to
|
|
317
|
+
// refresh, so folding them in would make a healthy run read as "1 of 2 refreshed".
|
|
318
|
+
console.log('sync-installed-profiles: ' + refreshed + ' of ' + installedProfiles + ' installed profile copy/copies refreshed')
|
|
@@ -187,6 +187,8 @@ describe('settings validation', () => {
|
|
|
187
187
|
expect(codesFor({ ...base, cacheDir: 'nested/verifier' }, 'cacheDir')).toEqual([])
|
|
188
188
|
expect(codesFor({ ...base, provider: '' }, 'provider')).toEqual(['required'])
|
|
189
189
|
expect(codesFor({ ...base, model: ' ' }, 'model')).toEqual(['required'])
|
|
190
|
+
expect(codesFor({ ...base, priceProviderOverride: '' }, 'priceProviderOverride')).toEqual([])
|
|
191
|
+
expect(codesFor({ ...base, priceProviderOverride: 'deepseek-official' }, 'priceProviderOverride')).toEqual([])
|
|
190
192
|
})
|
|
191
193
|
|
|
192
194
|
it('never blocks a missing custom rubric file: the resolver falls back and reports why', () => {
|