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.
Files changed (44) hide show
  1. package/README.md +8 -3
  2. package/lib/client.js +49 -23
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +334 -10
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/client-fields.d.ts +4 -0
  7. package/lib/types/client-fields.d.ts.map +1 -1
  8. package/lib/types/client-fields.js +21 -4
  9. package/lib/types/client-fields.js.map +1 -1
  10. package/lib/types/client-i18n.d.ts +24 -0
  11. package/lib/types/client-i18n.d.ts.map +1 -1
  12. package/lib/types/client-i18n.js +16 -0
  13. package/lib/types/client-i18n.js.map +1 -1
  14. package/lib/types/client.d.ts.map +1 -1
  15. package/lib/types/client.js +9 -8
  16. package/lib/types/client.js.map +1 -1
  17. package/lib/types/config.d.ts +12 -0
  18. package/lib/types/config.d.ts.map +1 -1
  19. package/lib/types/config.js +13 -1
  20. package/lib/types/config.js.map +1 -1
  21. package/lib/types/engine.d.ts +3 -0
  22. package/lib/types/engine.d.ts.map +1 -1
  23. package/lib/types/engine.js +7 -1
  24. package/lib/types/engine.js.map +1 -1
  25. package/lib/types/index.d.ts +1 -0
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/index.js +45 -6
  28. package/lib/types/index.js.map +1 -1
  29. package/lib/types/pricing.d.ts +132 -0
  30. package/lib/types/pricing.d.ts.map +1 -0
  31. package/lib/types/pricing.js +279 -0
  32. package/lib/types/pricing.js.map +1 -0
  33. package/package.json +1 -1
  34. package/scripts/sync-installed-profiles.mjs +226 -21
  35. package/src/client-fields.test.ts +2 -0
  36. package/src/client-fields.ts +27 -4
  37. package/src/client-i18n.ts +16 -0
  38. package/src/client.test.ts +59 -0
  39. package/src/client.tsx +11 -12
  40. package/src/config.ts +25 -1
  41. package/src/engine.ts +8 -2
  42. package/src/index.ts +45 -6
  43. package/src/pricing.test.ts +174 -0
  44. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-verifier",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Configurable DSH-native LLM verifier with a Web settings page",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 depends on this package.
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 installedProfiles() {
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 manifest = join(profilesDir, entry.name, 'package.json')
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 asDependency = pkg.dependencies?.[name] !== undefined
53
+ const dependency = pkg.dependencies?.[name]
41
54
  const asBundle = Array.isArray(pkg.dsh?.profile?.bundles) && pkg.dsh.profile.bundles.includes(name)
42
- if (asDependency || asBundle) found.push(join(profilesDir, entry.name))
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 = installedProfiles()
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
- console.log('sync-installed-profiles: ' + profile + ' declares ' + name + ' but has no installed copy; run pnpm install there')
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
- // Removing the entry is what makes pnpm re-create it from the freshly built files.
101
- rmSync(installed, { recursive: true, force: true })
102
- execFileSync('cmd', ['/c', 'pnpm install'], { cwd: profile, stdio: 'ignore' })
103
- breakHardlinks(installed)
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
- console.log('sync-installed-profiles: refreshed ' + installed)
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 build itself succeeded; a failed refresh is reported loudly but does not fail it.
108
- console.log('! sync-installed-profiles: could not refresh ' + installed + ' — ' + (error instanceof Error ? error.message : String(error)))
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
- console.log('sync-installed-profiles: ' + refreshed + ' of ' + profiles.length + ' profile(s) refreshed')
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', () => {