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
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
|
+
}
|