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
package/src/pricing.ts ADDED
@@ -0,0 +1,324 @@
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
+
34
+ /** Where the rates in effect came from; reported by the judge probe. */
35
+ export type PriceSource = 'manual' | 'catalog' | 'online' | 'none'
36
+
37
+ /** USD per million tokens for one route. */
38
+ export interface TokenPrices {
39
+ input: number
40
+ output: number
41
+ /** Price of a prompt token served from cache, which is typically 10–50x cheaper than `input`. */
42
+ cachedInput: number
43
+ source: PriceSource
44
+ }
45
+
46
+ /** Operator-configured rates, before any automatic lookup. */
47
+ export interface ManualPrices {
48
+ input: number
49
+ output: number
50
+ cachedInput: number
51
+ }
52
+
53
+ /** The token counts a cost estimate needs; a structural subset of `RunStats`. */
54
+ export interface PricedUsage {
55
+ inputTokens: number
56
+ cachedInputTokens: number
57
+ outputTokens: number
58
+ }
59
+
60
+ /** Rates for a route nothing could price. */
61
+ export const UNPRICED: TokenPrices = { input: 0, output: 0, cachedInput: 0, source: 'none' }
62
+
63
+ /** One route's rates inside a price table. */
64
+ export interface PriceTableEntry {
65
+ input: number
66
+ output: number
67
+ cachedInput: number
68
+ }
69
+
70
+ /** `provider\0model` → rates. */
71
+ export type PriceTable = Map<string, PriceTableEntry>
72
+
73
+ /**
74
+ * USD cost of one usage figure under a rate table.
75
+ *
76
+ * Cached prompt tokens are billed at their own rate instead of the full input rate. The old
77
+ * formula added them to `inputTokens` and multiplied by the input price, which over-reported
78
+ * every cache-heavy call by the cache-read discount — on a real acceptance run
79
+ * (2,374 uncached + 134,912 cached input tokens) that is the difference between ~$0.0004 and
80
+ * an order of magnitude more.
81
+ * @param usage - token counts of the call.
82
+ * @param prices - rates in effect for the route that produced them.
83
+ * @returns The estimated cost in USD.
84
+ */
85
+ export function costUsd(usage: PricedUsage, prices: Omit<TokenPrices, 'source'>): number {
86
+ return (usage.inputTokens * prices.input + usage.cachedInputTokens * prices.cachedInput + usage.outputTokens * prices.output) / 1_000_000
87
+ }
88
+
89
+ /** Identity of one route inside a {@link PriceTable}. */
90
+ export function priceKey(provider: string, model: string): string {
91
+ return provider + '\u0000' + model
92
+ }
93
+
94
+ function object(value: unknown): Record<string, unknown> | undefined {
95
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
96
+ }
97
+
98
+ function rate(value: unknown): number | undefined {
99
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
100
+ }
101
+
102
+ /** Reads one `{input, output, cacheRead|cache_read}` record; `undefined` when nothing usable is there. */
103
+ export function priceEntry(value: unknown): PriceTableEntry | undefined {
104
+ const row = object(value)
105
+ if (row === undefined) return undefined
106
+ const input = rate(row.input)
107
+ const output = rate(row.output)
108
+ if (input === undefined || output === undefined) return undefined
109
+ return { input, output, cachedInput: rate(row.cacheRead) ?? rate(row.cache_read) ?? input }
110
+ }
111
+
112
+ /**
113
+ * Merge one provider document into a table.
114
+ *
115
+ * Accepts both shapes this module reads: the pi-ai catalog (`{ api: { modelId: { cost } } }`,
116
+ * `cost` nested) and models.dev (`{ models: { modelId: { cost } } }`, model keys at the top of
117
+ * the provider object). Unrecognized entries are skipped, never fatal.
118
+ * @param provider - provider id the models belong to.
119
+ * @param document - parsed provider document.
120
+ * @param into - table to merge into.
121
+ * @returns How many models were added.
122
+ */
123
+ export function mergeProviderDocument(provider: string, document: unknown, into: PriceTable): number {
124
+ const root = object(document)
125
+ if (root === undefined) return 0
126
+ const models: Record<string, unknown> = {}
127
+ // pi-ai: one key per wire protocol, each holding { modelId: model }.
128
+ for (const [key, value] of Object.entries(root)) {
129
+ if (key === 'models') continue
130
+ const block = object(value)
131
+ if (block === undefined) continue
132
+ for (const [id, model] of Object.entries(block)) models[id] = model
133
+ }
134
+ // models.dev: a single { models: { modelId: model } } block.
135
+ for (const [id, model] of Object.entries(object(root.models) ?? {})) models[id] = model
136
+ let added = 0
137
+ for (const [id, model] of Object.entries(models)) {
138
+ const row = object(model)
139
+ if (row === undefined) continue
140
+ const entry = priceEntry(row.cost)
141
+ if (entry === undefined) continue
142
+ into.set(priceKey(provider, id), entry)
143
+ added += 1
144
+ }
145
+ return added
146
+ }
147
+
148
+ /** Parses one models.dev snapshot (`{ provider: { models } }`) into a table. */
149
+ export function parseModelsDevDocument(document: unknown, into: PriceTable): number {
150
+ let added = 0
151
+ for (const [provider, value] of Object.entries(object(document) ?? {})) added += mergeProviderDocument(provider, value, into)
152
+ return added
153
+ }
154
+
155
+ /** The catalog inside one package root, if the adapter is installed there. */
156
+ function catalogUnder(root: string): string | undefined {
157
+ const candidate = join(root, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data')
158
+ return existsSync(candidate) ? candidate : undefined
159
+ }
160
+
161
+ /**
162
+ * Locate the installed pi-ai catalog directory.
163
+ *
164
+ * The package's `exports` map publishes no subpath, so a module resolver cannot even name its
165
+ * files and this walks the filesystem instead: upward from this module (the plugin normally
166
+ * sits in the same `profiles/<name>/node_modules` tree as the adapter), then the host profile
167
+ * directories for a plugin loaded straight from a checkout. `DSH_VERIFIER_PI_AI_DATA`
168
+ * overrides both for a deployment whose layout differs.
169
+ *
170
+ * Returns `undefined` when the package is not installed — the caller then simply has one
171
+ * source fewer, which is the documented degradation and not an error.
172
+ * @param from - directory to start the upward walk from; defaults to this module's directory.
173
+ * @param home - DSH home whose `profiles` are searched last; defaults to `$DSH_HOME`.
174
+ * @returns Absolute `dist/providers/data` directory, or `undefined`.
175
+ */
176
+ export function resolveCatalogDataDir(from: string = dirname(fileURLToPath(import.meta.url)), home: string | undefined = process.env.DSH_HOME?.trim()): string | undefined {
177
+ const override = process.env.DSH_VERIFIER_PI_AI_DATA?.trim()
178
+ if (override) return existsSync(override) ? override : undefined
179
+ let dir = from
180
+ for (let depth = 0; depth < 12; depth += 1) {
181
+ const found = catalogUnder(dir)
182
+ if (found !== undefined) return found
183
+ const parent = dirname(dir)
184
+ if (parent === dir) break
185
+ dir = parent
186
+ }
187
+ if (!home) return undefined
188
+ const profiles = join(home, 'profiles')
189
+ const direct = catalogUnder(profiles)
190
+ if (direct !== undefined) return direct
191
+ try {
192
+ for (const entry of readdirSync(profiles, { withFileTypes: true })) {
193
+ if (!entry.isDirectory()) continue
194
+ const found = catalogUnder(join(profiles, entry.name))
195
+ if (found !== undefined) return found
196
+ }
197
+ } catch { /* no profiles directory: nothing left to find */ }
198
+ return undefined
199
+ }
200
+
201
+ /** Reads every `*.json` provider document in a catalog directory into a table. */
202
+ export function loadCatalogTable(dataDir: string): PriceTable {
203
+ const table: PriceTable = new Map()
204
+ let files: string[]
205
+ try {
206
+ files = readdirSync(dataDir).filter(name => name.endsWith('.json'))
207
+ } catch {
208
+ return table
209
+ }
210
+ for (const file of files) {
211
+ try {
212
+ mergeProviderDocument(file.slice(0, -'.json'.length), JSON.parse(readFileSync(join(dataDir, file), 'utf8')), table)
213
+ } catch { /* one unreadable provider must not lose the other 39 */ }
214
+ }
215
+ return table
216
+ }
217
+
218
+ /** Default lifetime of a loaded price table, matching the capability memory's TTL. */
219
+ export const PRICE_TTL_MS = 24 * 60 * 60 * 1000
220
+
221
+ /** Public models.dev snapshot; the upstream of the pi-ai catalog the host ships. */
222
+ export const MODELS_DEV_URL = 'https://models.dev/api.json'
223
+
224
+ export interface PriceResolverOptions {
225
+ /** Rates the operator typed; they win over every automatic source. */
226
+ manual: ManualPrices
227
+ /** Consult the installed catalog (default true). */
228
+ fromCatalog?: boolean
229
+ /** Consult models.dev when the catalog misses (default false; opt-in network). */
230
+ online?: boolean
231
+ /** Provider id whose list price to follow for a route the tables do not know. */
232
+ overrideProvider?: string
233
+ }
234
+
235
+ export interface PriceResolverDeps {
236
+ /** Injected HTTP client; tests must never reach the network. */
237
+ fetch?: typeof fetch
238
+ now?: () => number
239
+ catalogDir?: () => string | undefined
240
+ url?: string
241
+ ttlMs?: number
242
+ timeoutMs?: number
243
+ }
244
+
245
+ /**
246
+ * Resolves a route's rates once per TTL, caching both tables in-process.
247
+ *
248
+ * The two tables are independent: a broken catalog read still leaves the online lookup, and
249
+ * a network failure still leaves the catalog. Both are single-flighted so concurrent topics
250
+ * cannot each fetch the 4.6 MB models.dev snapshot.
251
+ */
252
+ export class PriceResolver {
253
+ private catalog: { at: number; table: PriceTable } | undefined
254
+ private online: { at: number; table: PriceTable } | undefined
255
+ private onlineFlight: Promise<PriceTable> | undefined
256
+
257
+ constructor(private readonly deps: PriceResolverDeps = {}) {}
258
+
259
+ /** The installed catalog table, loaded at most once per TTL. */
260
+ catalogTable(): PriceTable {
261
+ const now = (this.deps.now ?? Date.now)()
262
+ const ttl = this.deps.ttlMs ?? PRICE_TTL_MS
263
+ if (this.catalog !== undefined && now - this.catalog.at <= ttl) return this.catalog.table
264
+ const dir = (this.deps.catalogDir ?? (() => resolveCatalogDataDir()))()
265
+ const table = dir === undefined ? new Map() : loadCatalogTable(dir)
266
+ this.catalog = { at: now, table }
267
+ return table
268
+ }
269
+
270
+ /** The models.dev table, fetched at most once per TTL. Never rejects. */
271
+ async onlineTable(): Promise<PriceTable> {
272
+ const now = (this.deps.now ?? Date.now)()
273
+ const ttl = this.deps.ttlMs ?? PRICE_TTL_MS
274
+ if (this.online !== undefined && now - this.online.at <= ttl) return this.online.table
275
+ this.onlineFlight ??= (async () => {
276
+ const table: PriceTable = new Map()
277
+ try {
278
+ const fetchImpl = this.deps.fetch ?? fetch
279
+ const response = await fetchImpl(this.deps.url ?? MODELS_DEV_URL, { signal: AbortSignal.timeout(this.deps.timeoutMs ?? 20_000) })
280
+ if (response.ok) parseModelsDevDocument(JSON.parse(await response.text()), table)
281
+ } catch { /* offline, blocked, or a changed shape: no price beats a fabricated one */ }
282
+ this.online = { at: (this.deps.now ?? Date.now)(), table }
283
+ this.onlineFlight = undefined
284
+ return table
285
+ })()
286
+ return this.onlineFlight
287
+ }
288
+
289
+ private fromTable(table: PriceTable, providers: readonly string[], model: string): PriceTableEntry | undefined {
290
+ for (const provider of providers) {
291
+ const entry = table.get(priceKey(provider, model))
292
+ if (entry !== undefined) return entry
293
+ }
294
+ return undefined
295
+ }
296
+
297
+ /**
298
+ * Rates in effect for one route.
299
+ * @param provider - route's provider id.
300
+ * @param model - route's model id.
301
+ * @param options - manual rates plus which automatic sources may run.
302
+ * @returns The rates, always, with the source that answered.
303
+ */
304
+ async resolve(provider: string, model: string, options: PriceResolverOptions): Promise<TokenPrices> {
305
+ const manual = options.manual
306
+ if (manual.input > 0 || manual.output > 0) {
307
+ // A typed rate is a fact about the deployment. Cache reads keep the operator's own
308
+ // cached rate when they gave one; otherwise they fall back to the input rate, which is
309
+ // exactly the pre-existing estimate rather than silently inventing a discount.
310
+ return { input: manual.input, output: manual.output, cachedInput: manual.cachedInput > 0 ? manual.cachedInput : manual.input, source: 'manual' }
311
+ }
312
+ const override = options.overrideProvider?.trim()
313
+ const candidates = override && override !== provider ? [override, provider] : [provider]
314
+ if (options.fromCatalog !== false) {
315
+ const entry = this.fromTable(this.catalogTable(), candidates, model)
316
+ if (entry !== undefined) return { ...entry, source: 'catalog' }
317
+ }
318
+ if (options.online === true) {
319
+ const entry = this.fromTable(await this.onlineTable(), candidates, model)
320
+ if (entry !== undefined) return { ...entry, source: 'online' }
321
+ }
322
+ return UNPRICED
323
+ }
324
+ }