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
@@ -58,6 +58,10 @@ export interface Values {
58
58
  cacheMaxEntries: number
59
59
  estimatedInputUsdPerMillion: number
60
60
  estimatedOutputUsdPerMillion: number
61
+ estimatedCachedInputUsdPerMillion: number
62
+ autoPriceFromCatalog: boolean
63
+ autoPriceOnline: boolean
64
+ priceProviderOverride: string
61
65
  autoVerifySubagents: boolean
62
66
  extraJudges: ExtraJudgeDraft[]
63
67
  }
@@ -112,6 +116,10 @@ export const CONFIG_DEFAULTS: Values = {
112
116
  cacheMaxEntries: 10000,
113
117
  estimatedInputUsdPerMillion: 0,
114
118
  estimatedOutputUsdPerMillion: 0,
119
+ estimatedCachedInputUsdPerMillion: 0,
120
+ autoPriceFromCatalog: true,
121
+ autoPriceOnline: true,
122
+ priceProviderOverride: '',
115
123
  autoVerifySubagents: false,
116
124
  extraJudges: [],
117
125
  }
@@ -244,8 +252,12 @@ export const FIELDS: readonly FieldSpec[] = [
244
252
  number('retryBaseDelayMs', 'execution', { min: 1, integer: true, unitKey: 'settings.unit.ms' }),
245
253
  number('timeoutMs', 'execution', { min: 1, integer: true, unitKey: 'settings.unit.ms' }),
246
254
 
255
+ toggle('autoPriceFromCatalog', 'cost'),
256
+ toggle('autoPriceOnline', 'cost'),
257
+ text('priceProviderOverride', 'cost', true),
247
258
  number('estimatedInputUsdPerMillion', 'cost', { min: 0 }),
248
259
  number('estimatedOutputUsdPerMillion', 'cost', { min: 0 }),
260
+ number('estimatedCachedInputUsdPerMillion', 'cost', { min: 0 }),
249
261
  ]
250
262
 
251
263
  export function fieldsOfSection(section: SectionId): FieldSpec[] {
@@ -336,6 +348,10 @@ export function valuesFromView(view: Record<string, unknown> | undefined): Value
336
348
  cacheMaxEntries: numberOr('cacheMaxEntries'),
337
349
  estimatedInputUsdPerMillion: numberOr('estimatedInputUsdPerMillion'),
338
350
  estimatedOutputUsdPerMillion: numberOr('estimatedOutputUsdPerMillion'),
351
+ estimatedCachedInputUsdPerMillion: numberOr('estimatedCachedInputUsdPerMillion'),
352
+ autoPriceFromCatalog: v.autoPriceFromCatalog !== false,
353
+ autoPriceOnline: v.autoPriceOnline !== false,
354
+ priceProviderOverride: typeof v.priceProviderOverride === 'string' ? v.priceProviderOverride.trim() : '',
339
355
  autoVerifySubagents: v.autoVerifySubagents === true,
340
356
  extraJudges: normalizeExtraJudges(v.extraJudges),
341
357
  }
@@ -417,10 +433,17 @@ export function validateValues(values: Values): FieldIssue[] {
417
433
  }
418
434
  continue
419
435
  }
420
- // Empty is a legal value for the two identity-ish text fields: the custom criteria file falls
421
- // back to `coding`, and the alternative model falls back to the request's own route. Requiring
422
- // text here would reject a save the host accepts.
423
- if (field.kind === 'text' && field.key !== 'criteriaFile' && field.key !== 'label' && field.key !== 'autoProcessAlternativeModel') {
436
+ // Empty is a legal value for the optional text fields: the custom criteria file falls
437
+ // back to `coding`, the alternative model falls back to the request's own route, label falls
438
+ // back to provider/model, and priceProviderOverride defaults to empty (no cross-provider guessing).
439
+ // Requiring text here would reject a save the host accepts.
440
+ if (
441
+ field.kind === 'text' &&
442
+ field.key !== 'criteriaFile' &&
443
+ field.key !== 'label' &&
444
+ field.key !== 'autoProcessAlternativeModel' &&
445
+ field.key !== 'priceProviderOverride'
446
+ ) {
424
447
  const value = typeof raw === 'string' ? raw.trim() : ''
425
448
  if (!value) {
426
449
  issues.push({ key: field.key, code: 'required' })
@@ -127,6 +127,14 @@ export const zh = {
127
127
  'field.estimatedInputUsdPerMillion.help': '每百万输入 Token 的美元价格,仅用于统计估算。',
128
128
  'field.estimatedOutputUsdPerMillion.title': '输出价格',
129
129
  'field.estimatedOutputUsdPerMillion.help': '每百万输出 Token 的美元价格,仅用于统计估算。',
130
+ 'field.estimatedCachedInputUsdPerMillion.title': '缓存读取价格',
131
+ 'field.estimatedCachedInputUsdPerMillion.help': '每百万缓存命中输入 Token 的美元价格;留 0 时退回按输入价格计算(旧口径,对缓存密集的调用会高估)。',
132
+ 'field.autoPriceFromCatalog.title': '从本机模型目录自动定价',
133
+ 'field.autoPriceFromCatalog.help': '本机安装的 pi-ai 模型目录(models.dev 快照)里有这条 provider/模型时自动取出单价。手填的价格永远优先;目录里没有就交给在线价格库。',
134
+ 'field.autoPriceOnline.title': '在线价格库兜底',
135
+ 'field.autoPriceOnline.help': '本机目录查不到时向 models.dev 查一次(结果缓存 24 小时)。只按 provider + 模型精确匹配,绝不跨 provider 猜价——同一个模型 id 在不同转售商手里能差 6 倍。',
136
+ 'field.priceProviderOverride.title': '价格来源 Provider',
137
+ 'field.priceProviderOverride.help': '转售路由(例如 command-code)两张价格表里都没有时,填一个 provider id(例如 openrouter),按它的挂牌价折算。留空表示不猜:未定价的路由成本记为 0。',
130
138
 
131
139
  // Settings Actions & Alerts
132
140
  'settings.catalogFailures': '部分模型目录读取失败',
@@ -472,6 +480,14 @@ export const en: I18nDict = {
472
480
  'field.estimatedInputUsdPerMillion.help': 'USD price per million input tokens, for statistical estimation only.',
473
481
  'field.estimatedOutputUsdPerMillion.title': 'Output Price',
474
482
  'field.estimatedOutputUsdPerMillion.help': 'USD price per million output tokens, for statistical estimation only.',
483
+ 'field.estimatedCachedInputUsdPerMillion.title': 'Cached Input Price',
484
+ 'field.estimatedCachedInputUsdPerMillion.help': 'USD per million cached prompt tokens. Left at 0 it falls back to the input price, which is the old estimate and over-reports cache-heavy calls.',
485
+ 'field.autoPriceFromCatalog.title': 'Auto Price From Catalog',
486
+ 'field.autoPriceFromCatalog.help': 'Price this judge route from the installed pi-ai model catalog (a models.dev snapshot). Typed prices always win; a route the catalog does not describe moves on to the online lookup.',
487
+ 'field.autoPriceOnline.title': 'Online Price Lookup',
488
+ 'field.autoPriceOnline.help': 'When the installed catalog has no entry, query models.dev once (cached for 24 hours). Matching is exact provider + model only — prices are never guessed across providers, where the same model id can differ sixfold.',
489
+ 'field.priceProviderOverride.title': 'Price Source Provider',
490
+ 'field.priceProviderOverride.help': 'For a reseller route neither price table knows (for example command-code), name the provider whose list price to follow (for example openrouter). Empty means never guess and leave the route unpriced.',
475
491
 
476
492
  // Settings Actions & Alerts
477
493
  'settings.catalogFailures': 'Some model catalogs failed to load',
@@ -805,5 +805,64 @@ describe('client design tokens', () => {
805
805
  })
806
806
  })
807
807
 
808
+ /** Body of one exported function declaration, up to its column-zero closing brace. */
809
+ function declarationBody(source: string, name: string): string {
810
+ const start = source.indexOf('export function ' + name + '(')
811
+ expect(start, name).toBeGreaterThanOrEqual(0)
812
+ const end = source.indexOf('\n}\n', start)
813
+ return source.slice(start, end === -1 ? undefined : end)
814
+ }
815
+
816
+ /** One `const NAME: React.CSSProperties = { ... }` object literal, up to its column-zero brace. */
817
+ function styleObject(source: string, name: string): string {
818
+ const start = source.indexOf('const ' + name + ': React.CSSProperties = {')
819
+ expect(start, name).toBeGreaterThanOrEqual(0)
820
+ const end = source.indexOf('\n}', start)
821
+ expect(end, name).toBeGreaterThan(start)
822
+ return source.slice(start, end)
823
+ }
824
+
825
+ /**
826
+ * The activity card is ONE element that owns both the dock-column width and the card surface.
827
+ *
828
+ * The composer stack is a host region a host or skin stylesheet may paint (the dock row, for
829
+ * instance with the tip surface). A wrapper holding the dock-column width but no background of its
830
+ * own then shows that paint as a "mask" reaching well past the card on both sides — the reported
831
+ * "背景遮罩超出了位置" — because a background is only ever clipped to the border box it is declared
832
+ * on. The host's own TodoPanel does the same single-element thing for the same reason, so this also
833
+ * keeps the chip a member of that family. Splitting the card back into a wrapper plus an inner bar
834
+ * reintroduces the bug whatever the inner element looks like, hence the regression.
835
+ */
836
+ describe('client activity card structure', () => {
837
+ const read = (): string => readFileSync(fileURLToPath(new URL('./client.tsx', import.meta.url)), 'utf8')
838
+
839
+ it('renders the chip as a single box', () => {
840
+ const body = declarationBody(read(), 'VerifierActivityChip')
841
+ expect(body.match(/<div/g)?.length).toBe(1)
842
+ expect(body).toContain('style={activityCard}')
843
+ })
844
+
845
+ it('declares the dock-column width and the card surface on that one element', () => {
846
+ const card = styleObject(read(), 'activityCard')
847
+ // The width axis (chat content column minus the composer clearances and the four dock insets).
848
+ expect(card).toContain('--dsh-composer-side-clearance')
849
+ expect(card).toContain('maxWidth')
850
+ expect(card).toContain('--dsh-composer-card-max-width')
851
+ // The surface, its stroke and the card's fixed height, on the very same element.
852
+ expect(card).toContain('borderRadius: 12')
853
+ expect(card).toContain('border:')
854
+ expect(card).toContain('--dsw-alias-border-l1')
855
+ expect(card).toContain('background:')
856
+ expect(card).toContain('--dsw-specific-tip')
857
+ expect(card).toContain('height: 36')
858
+ })
859
+
860
+ it('leaves no second element on that width axis for a host-painted row surface to show through', () => {
861
+ const source = read()
862
+ const dockWidths = source.match(/width: 'calc\(100% - var\(--dsh-composer-side-clearance/g) ?? []
863
+ expect(dockWidths.length).toBe(1)
864
+ })
865
+ })
866
+
808
867
 
809
868
 
package/src/client.tsx CHANGED
@@ -1000,18 +1000,19 @@ async function readVerifierActivity(rpc: any, sessionId: string, signal: AbortSi
1000
1000
  * four dock insets — and the fallbacks below only matter on a host that does not publish the three
1001
1001
  * custom properties. Without this clamp the status line stretches to the window edge and its text
1002
1002
  * sits far left of the composer it belongs to, which is exactly the misplacement it is here to fix.
1003
+ *
1004
+ * ONE element owns BOTH the dock-column width and the card surface. The composer stack is a host
1005
+ * region a host or skin stylesheet may paint (the dock row, for instance with the tip surface), and
1006
+ * a wider wrapper that paints no background of its own would show that paint as a mask reaching
1007
+ * well past the card on both sides. The surface here is declared inline, so it is itself the card:
1008
+ * a background can never be painted outside the border box it is declared on.
1003
1009
  */
1004
- const activityDock: React.CSSProperties = {
1005
- boxSizing: 'border-box',
1006
- width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-dock-inset, 8px) - var(--dsh-composer-dock-inset, 8px) - var(--dsh-composer-dock-inset, 8px) - var(--dsh-composer-dock-inset, 8px))',
1007
- margin: '0 auto',
1008
- }
1009
- const activityBar: React.CSSProperties = {
1010
+ const activityCard: React.CSSProperties = {
1010
1011
  boxSizing: 'border-box',
1011
1012
  display: 'flex',
1012
1013
  alignItems: 'center',
1013
1014
  gap: 10,
1014
- width: '100%',
1015
+ width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-dock-inset, 8px) - var(--dsh-composer-dock-inset, 8px) - var(--dsh-composer-dock-inset, 8px) - var(--dsh-composer-dock-inset, 8px))',
1015
1016
  maxWidth: 'calc(var(--dsh-composer-card-max-width, 952px) - 4 * var(--dsh-composer-dock-inset, 8px))',
1016
1017
  height: 36,
1017
1018
  margin: '0 auto',
@@ -1066,11 +1067,9 @@ export function VerifierActivityChip({ session, rpc }: { session?: { sessionId?:
1066
1067
  if (rendered === null) return null
1067
1068
  const dotState: StateDotState = rendered.tone === 'busy' ? 'ongoing' : rendered.tone === 'ok' ? 'done' : 'error'
1068
1069
  return (
1069
- <div style={activityDock}>
1070
- <div style={activityBar} role="status" aria-live="polite">
1071
- <StateDot state={dotState} size={8} />
1072
- <span style={activityTextStyle}>{rendered.text}</span>
1073
- </div>
1070
+ <div style={activityCard} role="status" aria-live="polite">
1071
+ <StateDot state={dotState} size={8} />
1072
+ <span style={activityTextStyle}>{rendered.text}</span>
1074
1073
  </div>
1075
1074
  )
1076
1075
  }
package/src/config.ts CHANGED
@@ -129,6 +129,14 @@ export interface Config {
129
129
  cacheMaxEntries?: number
130
130
  estimatedInputUsdPerMillion?: number
131
131
  estimatedOutputUsdPerMillion?: number
132
+ /** USD per million prompt tokens served from cache; 0 falls back to the input rate. */
133
+ estimatedCachedInputUsdPerMillion?: number
134
+ /** Price a judge route from the installed pi-ai catalog when the operator typed no rate. */
135
+ autoPriceFromCatalog?: boolean
136
+ /** Consult the models.dev snapshot when the installed catalog has no price for the route. */
137
+ autoPriceOnline?: boolean
138
+ /** Provider id whose list price to follow for a route neither price table knows. */
139
+ priceProviderOverride?: string
132
140
  extraJudges?: JudgeConfig[]
133
141
  }
134
142
 
@@ -176,6 +184,10 @@ export interface ResolvedConfig {
176
184
  cacheMaxEntries: number
177
185
  estimatedInputUsdPerMillion: number
178
186
  estimatedOutputUsdPerMillion: number
187
+ estimatedCachedInputUsdPerMillion: number
188
+ autoPriceFromCatalog: boolean
189
+ autoPriceOnline: boolean
190
+ priceProviderOverride: string
179
191
  judges: ResolvedJudge[]
180
192
  }
181
193
 
@@ -232,6 +244,10 @@ export const Config: z<Config> = z.object({
232
244
  cacheMaxEntries: z.number().step(1).min(1).default(10000),
233
245
  estimatedInputUsdPerMillion: z.number().min(0).default(0),
234
246
  estimatedOutputUsdPerMillion: z.number().min(0).default(0),
247
+ estimatedCachedInputUsdPerMillion: z.number().min(0).default(0),
248
+ autoPriceFromCatalog: z.boolean().default(true),
249
+ autoPriceOnline: z.boolean().default(true),
250
+ priceProviderOverride: z.string().default(''),
235
251
  extraJudges: z.array(z.object({
236
252
  provider: z.string(),
237
253
  model: z.string(),
@@ -326,7 +342,11 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
326
342
  if (cacheDir.split(/[\\/]+/u).includes('..')) throw new Error('llm-verifier: cacheDir must stay inside the topic directory')
327
343
  const estimatedInputUsdPerMillion = config.estimatedInputUsdPerMillion ?? 0
328
344
  const estimatedOutputUsdPerMillion = config.estimatedOutputUsdPerMillion ?? 0
329
- if (![estimatedInputUsdPerMillion, estimatedOutputUsdPerMillion].every(value => Number.isFinite(value) && value >= 0)) throw new Error('llm-verifier: estimated token prices must be finite non-negative numbers')
345
+ const estimatedCachedInputUsdPerMillion = config.estimatedCachedInputUsdPerMillion ?? 0
346
+ if (![estimatedInputUsdPerMillion, estimatedOutputUsdPerMillion, estimatedCachedInputUsdPerMillion].every(value => Number.isFinite(value) && value >= 0)) throw new Error('llm-verifier: estimated token prices must be finite non-negative numbers')
347
+ // A reseller route has no price in either table; the override names the provider whose list
348
+ // price the operator chose to follow. Empty (the default) means "never guess across providers".
349
+ const priceProviderOverride = (config.priceProviderOverride ?? '').trim()
330
350
  const criteriaPreset = config.criteriaPreset ?? 'coding'
331
351
  if (criteriaPreset !== 'custom' && !CRITERIA_PRESET_IDS.includes(criteriaPreset as CriteriaPresetId)) throw new Error('llm-verifier: criteriaPreset must be one of ' + [...CRITERIA_PRESET_IDS, 'custom'].join(', '))
332
352
  // A half-configured custom rubric is NOT a config error: CriteriaResolver falls back to the
@@ -420,6 +440,10 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
420
440
  cacheDir,
421
441
  estimatedInputUsdPerMillion,
422
442
  estimatedOutputUsdPerMillion,
443
+ estimatedCachedInputUsdPerMillion,
444
+ autoPriceFromCatalog: config.autoPriceFromCatalog ?? true,
445
+ autoPriceOnline: config.autoPriceOnline ?? true,
446
+ priceProviderOverride,
423
447
  ...values,
424
448
  temperature,
425
449
  judges,
package/src/engine.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { addUsage, attachUsage, callVerifier, emptyUsage, partialUsage, predictScoringChannel, requestAttempts, type ScoringMode, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
2
2
  import { ScoreCache, SingleFlight, stableHash, type CachedPairScore } from './cache.ts'
3
3
  import type { DecisionTrace } from './decisions.ts'
4
+ import { costUsd } from './pricing.ts'
4
5
  import {
5
6
  DEFAULT_CRITERIA, DEFAULT_GROUND_TRUTH_NOTE, PROPOSAL_CRITERIA, accumulatePairs, buildPairwisePrompt, buildProgressPrompt,
6
7
  dedupeCriterionId, diagnosticKey, extractProgressScore, extractScore, parseDiagnostics, pivotRoundPairs, rankScores,
@@ -314,13 +315,15 @@ export class VerifierEngine {
314
315
  readonly cache: ScoreCache | undefined
315
316
  readonly inputPrice: number
316
317
  readonly outputPrice: number
318
+ /** Cache-read rate; an omitted one falls back to the input rate. */
319
+ readonly cachedInputPrice: number
317
320
  private readonly flights: SingleFlight<{ value: CachedPairScore; hit: boolean }>
318
321
 
319
322
  constructor(
320
323
  client: VerifierClientConfig | readonly VerifierClientConfig[],
321
324
  maxConcurrency = 8,
322
325
  cache?: ScoreCache,
323
- prices: { input: number; output: number } = { input: 0, output: 0 },
326
+ prices: { input: number; output: number; cachedInput?: number } = { input: 0, output: 0 },
324
327
  flights: SingleFlight<{ value: CachedPairScore; hit: boolean }> = new SingleFlight(),
325
328
  ) {
326
329
  const list = Array.isArray(client) ? client : [client]
@@ -331,11 +334,14 @@ export class VerifierEngine {
331
334
  this.cache = cache
332
335
  this.inputPrice = prices.input
333
336
  this.outputPrice = prices.output
337
+ // Backward compatible default: a caller that only knows two rates keeps the old estimate
338
+ // (cache reads at the input rate) instead of suddenly pricing them at zero.
339
+ this.cachedInputPrice = prices.cachedInput ?? prices.input
334
340
  this.flights = flights
335
341
  }
336
342
 
337
343
  private finishStats(stats: RunStats): RunStats {
338
- stats.estimatedCostUsd = ((stats.inputTokens + stats.cachedInputTokens) * this.inputPrice + stats.outputTokens * this.outputPrice) / 1_000_000
344
+ stats.estimatedCostUsd = costUsd(stats, { input: this.inputPrice, output: this.outputPrice, cachedInput: this.cachedInputPrice })
339
345
  return stats
340
346
  }
341
347
 
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import { inspectTeamTasks, buildTeamTaskVerificationPrompt } from './team-gate.t
23
23
  import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, summarizeVerdict, type RouteObservation, type StatisticsOverview, type VerdictSummary, type VerifierToolName } from './statistics.ts'
24
24
  import { resolveTopicDataDir, type SessionArtifactLocator } from './topic-storage.ts'
25
25
  import { DecisionStore, boundDecisionCalls, resolveDecisionsFile, type DecisionCall, type DecisionRecord, type DecisionTrace } from './decisions.ts'
26
+ import { PriceResolver, costUsd } from './pricing.ts'
26
27
 
27
28
  export const name = 'llm-verifier'
28
29
  export const inject = ['tools', 'agents', 'attachments', 'llm', 'connection', 'sessionPersistence']
@@ -35,6 +36,7 @@ export * from './topic-storage.ts'
35
36
  export * from './decisions.ts'
36
37
  export * from './auto.ts'
37
38
  export * from './router.ts'
39
+ export * from './pricing.ts'
38
40
  export * from './plan-gate.ts'
39
41
  export * from './team-gate.ts'
40
42
  export { callVerifier, RequestLimiter, type VerifierClientConfig, type VerifierImage, type UsageStats, type VerifierCompletion } from './caller.ts'
@@ -203,6 +205,32 @@ export function apply(ctx: Context, config: Config = {}): void {
203
205
  // verdict, never the model history (see `verifier-activity.ts` for why it is not a session event).
204
206
  const activities = new VerifierActivities()
205
207
  const autoRouter = new AutoVerifierRouter(createActivityObserver(activities))
208
+ // Automatic judge pricing: typed rates win, otherwise the installed pi-ai catalog and (when
209
+ // enabled) the models.dev snapshot. One resolver per plugin instance keeps both tables cached.
210
+ const priceResolver = new PriceResolver()
211
+ /**
212
+ * Rates in effect for one route.
213
+ *
214
+ * The operator's typed rates always win. Otherwise the installed catalog prices the route
215
+ * offline, and models.dev is consulted only when that misses. Nothing is ever inferred from a
216
+ * model id alone: the same id sells for 0.10/0.40 at one reseller and 0.65/1.45 at another, so
217
+ * a cross-provider guess would be a fabricated number in a cost column. `priceProviderOverride`
218
+ * is how an operator points a reseller route at the list price they chose to follow.
219
+ * @param provider - route's provider id.
220
+ * @param model - route's model id.
221
+ * @param selected - configuration in effect.
222
+ * @returns The rates plus the source that answered, never throwing.
223
+ */
224
+ const pricesFor = (provider: string, model: string, selected: ReturnType<typeof current>) => priceResolver.resolve(provider, model, {
225
+ manual: {
226
+ input: selected.estimatedInputUsdPerMillion,
227
+ output: selected.estimatedOutputUsdPerMillion,
228
+ cachedInput: selected.estimatedCachedInputUsdPerMillion,
229
+ },
230
+ fromCatalog: selected.autoPriceFromCatalog,
231
+ online: selected.autoPriceOnline,
232
+ ...(selected.priceProviderOverride ? { overrideProvider: selected.priceProviderOverride } : {}),
233
+ })
206
234
  const topics = new Map<string, { dataDir: string; cache: ScoreCache; capabilities: TopLogprobCapabilityCache; flights: SingleFlight<{ value: CachedPairScore; hit: boolean }>; statistics: StatisticsStore; decisions: DecisionStore; process: ProcessCycleStore }>()
207
235
  const topic = (header: SessionHeader) => {
208
236
  const selected = current()
@@ -302,7 +330,10 @@ export function apply(ctx: Context, config: Config = {}): void {
302
330
  await ctx.llm.resolveCallConfig({ provider: judge.provider, model: judge.model, ...(judge.reasoningEffort ? { reasoningEffort: judge.reasoningEffort as never } : {}), maxTokens: judge.maxTokens })
303
331
  clients.push({ ctx, llm: ctx.llm, attachments: services.attachments, topLogprobCapabilities: topicEntry.capabilities, provider: judge.provider, model: judge.model, temperature: selected.temperature, label: judge.label, ...(judge.reasoningEffort ? { reasoningEffort: judge.reasoningEffort } : {}), maxTokens: judge.maxTokens, timeoutMs: selected.timeoutMs, maxRetries: selected.maxRetries, retryBaseDelayMs: selected.retryBaseDelayMs, limiter })
304
332
  }
305
- return { verifier: new VerifierEngine(clients, selected.maxConcurrency, topicEntry.cache, { input: selected.estimatedInputUsdPerMillion, output: selected.estimatedOutputUsdPerMillion }, topicEntry.flights), selected }
333
+ // One rate table for the whole invocation, taken from the primary judge: the engine reports
334
+ // one cost line per call, exactly as it did with the operator's single manual table.
335
+ const prices = await pricesFor(selected.provider, selected.model, selected)
336
+ return { verifier: new VerifierEngine(clients, selected.maxConcurrency, topicEntry.cache, { input: prices.input, output: prices.output, cachedInput: prices.cachedInput }, topicEntry.flights), selected, prices }
306
337
  }
307
338
  const engine = async (agent: Agent) => engineForHeader(agent.session.header)
308
339
  const images = (values: readonly string[] | undefined, signal: AbortSignal) => loadVerifierImages(values, signal)
@@ -474,6 +505,9 @@ export function apply(ctx: Context, config: Config = {}): void {
474
505
  const call = agent.session.requestHeader()?.config
475
506
  if (call === undefined) throw new Error('llm-verifier: this session has no logged request header yet, so best-of-n cannot tell which model should write the drafts — generate candidates with parallel subagents and rank them with verifier_select instead')
476
507
  const target = { provider: call.provider, model: call.model, ...(call.reasoningEffort === undefined ? {} : { reasoningEffort: String(call.reasoningEffort) }) }
508
+ // The drafts come from the session model, so they are priced with THAT route's rates rather
509
+ // than with the judge's, which is what the single manual table could only approximate.
510
+ const generationPrices = await pricesFor(target.provider, target.model, selected)
477
511
  // The task and the optional reference context share ONE explicit budget, each with its own item
478
512
  // cap, so a huge context cannot push the combined request past the documented ceiling.
479
513
  const contextRaw = typeof contextInput === 'string' ? contextInput : undefined
@@ -495,7 +529,7 @@ export function apply(ctx: Context, config: Config = {}): void {
495
529
  const generation = emptyRunStats()
496
530
  let unknownDrafts = 0
497
531
  const finishGeneration = (): RunStats => {
498
- generation.estimatedCostUsd = ((generation.inputTokens + generation.cachedInputTokens) * selected.estimatedInputUsdPerMillion + generation.outputTokens * selected.estimatedOutputUsdPerMillion) / 1_000_000
532
+ generation.estimatedCostUsd = costUsd(generation, generationPrices)
499
533
  return generation
500
534
  }
501
535
  const failWithUsage = (error: Error): never => {
@@ -623,10 +657,10 @@ export function apply(ctx: Context, config: Config = {}): void {
623
657
  return { result, selected }
624
658
  })
625
659
  const classifyRoute = async (agent: Agent, prompt: string, signal: AbortSignal, phase: string, observation?: RouteObservation) => record('verifier_route_classify', agent, async (trace) => {
626
- const { verifier, selected } = await engine(agent)
660
+ const { verifier, selected, prices } = await engine(agent)
627
661
  const completion = await callVerifierText(verifier.client, prompt, signal)
628
662
  trace?.({ label: 'route classify', channel: completion.scoringMode, prompt, output: completion.text })
629
- const stats: RunStats = { ...completion.usage, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: ((completion.usage.inputTokens + completion.usage.cachedInputTokens) * selected.estimatedInputUsdPerMillion + completion.usage.outputTokens * selected.estimatedOutputUsdPerMillion) / 1_000_000, topLogprobScores: 0, explicitTagScores: 1 }
663
+ const stats: RunStats = { ...completion.usage, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: costUsd(completion.usage, prices), topLogprobScores: 0, explicitTagScores: 1 }
630
664
  return { result: { text: completion.text, stats }, selected }
631
665
  }, phase, observation)
632
666
  const extractTask = async (agent: Agent, fromSeq: number, toSeq: number, maxChars: number, signal: AbortSignal) => extractSession(agent, async (ref: ImageAttachmentRef) => { const stored = await services.attachments.readImage(ref, signal); return { data: stored.data, mediaType: stored.ref.mediaType } }, { fromSeq, toSeq, includeAssistantText: true, maxChars })
@@ -843,7 +877,7 @@ export function apply(ctx: Context, config: Config = {}): void {
843
877
  // the verifier table (the same estimate best-of-n documents). Tokens are reported too.
844
878
  const stats: RunStats = {
845
879
  ...report.usage,
846
- estimatedCostUsd: ((report.usage.inputTokens + report.usage.cachedInputTokens) * selected.estimatedInputUsdPerMillion + report.usage.outputTokens * selected.estimatedOutputUsdPerMillion) / 1_000_000,
880
+ estimatedCostUsd: costUsd(report.usage, await pricesFor(selected.provider, selected.model, selected)),
847
881
  }
848
882
  // The row is the SAME shape for both cycle sizes; only the tool name and the summarizer differ,
849
883
  // so a tournament row still reports the process phase, the route observation and its usage.
@@ -1013,6 +1047,10 @@ export function apply(ctx: Context, config: Config = {}): void {
1013
1047
  for (const client of verifier.clients) {
1014
1048
  const label = client.label ?? client.provider + '/' + client.model
1015
1049
  const startedAt = Date.now()
1050
+ // The diagnostic answers "which rate is my judge being priced at, and why", which is the
1051
+ // only place a silently auto-derived (or still unpriced) route is visible.
1052
+ const prices = await pricesFor(client.provider, client.model, current())
1053
+ const priceView = { input: prices.input, output: prices.output, cachedInput: prices.cachedInput, source: prices.source }
1016
1054
  // Forget the cached channel verdict for this judge before probing. A mark written up to 24h
1017
1055
  // ago — possibly before the provider was (re)configured, and certainly before a host
1018
1056
  // restart — would make the probe replay a stale answer instead of checking, which is the one
@@ -1028,13 +1066,14 @@ export function apply(ctx: Context, config: Config = {}): void {
1028
1066
  ok: true,
1029
1067
  channelProbed: true,
1030
1068
  channel: completion.scoringMode,
1069
+ prices: priceView,
1031
1070
  scoreA: extractScore(completion, '<score_A>'),
1032
1071
  scoreB: extractScore(completion, '<score_B>'),
1033
1072
  latencyMs: Date.now() - startedAt,
1034
1073
  ...completion.usage,
1035
1074
  })
1036
1075
  } catch (error) {
1037
- judges.push({ label, provider: client.provider, model: client.model, ok: false, latencyMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error) })
1076
+ judges.push({ label, provider: client.provider, model: client.model, ok: false, prices: priceView, latencyMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error) })
1038
1077
  }
1039
1078
  }
1040
1079
  return rpcSuccess({
@@ -0,0 +1,174 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import {
6
+ MODELS_DEV_URL,
7
+ PRICE_TTL_MS,
8
+ PriceResolver,
9
+ costUsd,
10
+ loadCatalogTable,
11
+ mergeProviderDocument,
12
+ parseModelsDevDocument,
13
+ priceKey,
14
+ resolveCatalogDataDir,
15
+ type PriceTable,
16
+ } from './pricing.ts'
17
+
18
+ const manual = { input: 0, output: 0, cachedInput: 0 }
19
+
20
+ const dirs: string[] = []
21
+ afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
22
+
23
+ /** A catalog directory holding one provider document, in the installed pi-ai shape. */
24
+ function catalogDir(provider: string, document: unknown): string {
25
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-prices-'))
26
+ dirs.push(dir)
27
+ writeFileSync(join(dir, provider + '.json'), JSON.stringify(document))
28
+ return dir
29
+ }
30
+
31
+ const PI_AI_DOCUMENT = {
32
+ 'openai-completions': {
33
+ 'deepseek-v4-flash': {
34
+ id: 'deepseek-v4-flash',
35
+ cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
36
+ },
37
+ 'no-cost-model': { id: 'no-cost-model' },
38
+ },
39
+ }
40
+
41
+ describe('costUsd', () => {
42
+ it('bills cached prompt tokens at their own rate instead of the input rate', () => {
43
+ const prices = { input: 0.15, output: 0.6, cachedInput: 0.003, source: 'catalog' as const }
44
+ const usage = { inputTokens: 1_000_000, cachedInputTokens: 1_000_000, outputTokens: 0 }
45
+ expect(costUsd(usage, prices)).toBeCloseTo(0.15 + 0.003, 10)
46
+ // The old formula (inputTokens + cachedInputTokens) * input is 100x the cache-read price.
47
+ expect(costUsd(usage, prices)).toBeLessThan(0.15 * 2)
48
+ })
49
+
50
+ it('prices an unpriced route at zero rather than failing', () => {
51
+ expect(costUsd({ inputTokens: 10_000, cachedInputTokens: 90_000, outputTokens: 5_000 }, { input: 0, output: 0, cachedInput: 0, source: 'none' })).toBe(0)
52
+ })
53
+ })
54
+
55
+ describe('catalog parsing', () => {
56
+ it('reads the installed pi-ai shape, keyed by protocol block', () => {
57
+ const table: PriceTable = new Map()
58
+ expect(mergeProviderDocument('deepseek', PI_AI_DOCUMENT, table)).toBe(1)
59
+ expect(table.get(priceKey('deepseek', 'deepseek-v4-flash'))).toEqual({ input: 0.14, output: 0.28, cachedInput: 0.0028 })
60
+ expect(table.has(priceKey('deepseek', 'no-cost-model'))).toBe(false)
61
+ })
62
+
63
+ it('reads the models.dev shape, including snake_case cache fields', () => {
64
+ const table: PriceTable = new Map()
65
+ const added = parseModelsDevDocument({
66
+ openrouter: { id: 'openrouter', models: { 'deepseek/deepseek-v4.1-flash': { id: 'deepseek/deepseek-v4.1-flash', cost: { input: 0.15, output: 0.6, cache_read: 0.003 } } } },
67
+ 'no-models': { id: 'no-models' },
68
+ }, table)
69
+ expect(added).toBe(1)
70
+ expect(table.get(priceKey('openrouter', 'deepseek/deepseek-v4.1-flash'))).toEqual({ input: 0.15, output: 0.6, cachedInput: 0.003 })
71
+ })
72
+
73
+ it('loads every provider document in a directory and survives a corrupt one', () => {
74
+ const dir = catalogDir('deepseek', PI_AI_DOCUMENT)
75
+ writeFileSync(join(dir, 'broken.json'), '{ not json')
76
+ const table = loadCatalogTable(dir)
77
+ expect(table.size).toBe(1)
78
+ expect(loadCatalogTable(join(dir, 'missing'))).toEqual(new Map())
79
+ })
80
+
81
+ it('finds the adapter catalog by walking the install tree, and reports none instead of guessing', () => {
82
+ const root = mkdtempSync(join(tmpdir(), 'dsh-verifier-pkg-'))
83
+ dirs.push(root)
84
+ const data = join(root, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data')
85
+ mkdirSync(data, { recursive: true })
86
+ // The plugin walks up from its own lib/ directory, which is how a profile install resolves.
87
+ expect(resolveCatalogDataDir(join(root, 'node_modules', 'dsh-llm-verifier', 'lib'), undefined)).toBe(data)
88
+ const empty = mkdtempSync(join(tmpdir(), 'dsh-verifier-pkg-'))
89
+ dirs.push(empty)
90
+ expect(resolveCatalogDataDir(empty, undefined)).toBeUndefined()
91
+ })
92
+ })
93
+
94
+ describe('PriceResolver', () => {
95
+ it('lets the operator typed rates win over every automatic source', async () => {
96
+ const resolver = new PriceResolver({ catalogDir: () => catalogDir('deepseek', PI_AI_DOCUMENT), fetch: vi.fn() as never })
97
+ const prices = await resolver.resolve('deepseek', 'deepseek-v4-flash', { manual: { input: 9, output: 9, cachedInput: 9 } })
98
+ expect(prices).toEqual({ input: 9, output: 9, cachedInput: 9, source: 'manual' })
99
+ })
100
+
101
+ it('falls back to the input rate for cache reads when the operator typed only two rates', async () => {
102
+ const resolver = new PriceResolver({})
103
+ const prices = await resolver.resolve('x', 'y', { manual: { input: 0.5, output: 1.5, cachedInput: 0 } })
104
+ expect(prices.cachedInput).toBe(0.5)
105
+ expect(prices.source).toBe('manual')
106
+ })
107
+
108
+ it('prices a catalog route with no configuration at all', async () => {
109
+ const resolver = new PriceResolver({ catalogDir: () => catalogDir('deepseek', PI_AI_DOCUMENT) })
110
+ const prices = await resolver.resolve('deepseek', 'deepseek-v4-flash', { manual })
111
+ expect(prices).toEqual({ input: 0.14, output: 0.28, cachedInput: 0.0028, source: 'catalog' })
112
+ })
113
+
114
+ it('consults the online snapshot only after the catalog misses, and only when enabled', async () => {
115
+ const fetcher = vi.fn(async () => new Response(JSON.stringify({
116
+ openrouter: { models: { 'deepseek/deepseek-v4.1-flash': { cost: { input: 0.15, output: 0.6, cache_read: 0.003 } } } },
117
+ })))
118
+ const resolver = new PriceResolver({ catalogDir: () => catalogDir('deepseek', PI_AI_DOCUMENT), fetch: fetcher as never })
119
+ const offline = await resolver.resolve('openrouter', 'deepseek/deepseek-v4.1-flash', { manual })
120
+ expect(offline.source).toBe('none')
121
+ expect(fetcher).not.toHaveBeenCalled()
122
+
123
+ const online = await resolver.resolve('openrouter', 'deepseek/deepseek-v4.1-flash', { manual, online: true })
124
+ expect(online).toEqual({ input: 0.15, output: 0.6, cachedInput: 0.003, source: 'online' })
125
+ expect(fetcher).toHaveBeenCalledTimes(1)
126
+ // A second route reuses the cached snapshot instead of re-fetching 4.6 MB.
127
+ await resolver.resolve('openrouter', 'deepseek/deepseek-v4.1-flash', { manual, online: true })
128
+ expect(fetcher).toHaveBeenCalledTimes(1)
129
+ })
130
+
131
+ it('prices a reseller route only through an explicit provider override, never by guessing', async () => {
132
+ const fetcher = vi.fn(async () => new Response(JSON.stringify({
133
+ openrouter: { models: { 'deepseek/deepseek-v4.1-flash': { cost: { input: 0.15, output: 0.6, cache_read: 0.003 } } } },
134
+ 'nano-gpt': { models: { 'deepseek/deepseek-v4.1-flash': { cost: { input: 0.65, output: 1.45, cache_read: 0.13 } } } },
135
+ })))
136
+ const resolver = new PriceResolver({ catalogDir: () => undefined, fetch: fetcher as never })
137
+ const unguarded = await resolver.resolve('command-code', 'deepseek/deepseek-v4.1-flash', { manual, online: true })
138
+ expect(unguarded.source).toBe('none')
139
+
140
+ const guarded = await resolver.resolve('command-code', 'deepseek/deepseek-v4.1-flash', { manual, online: true, overrideProvider: 'openrouter' })
141
+ expect(guarded).toEqual({ input: 0.15, output: 0.6, cachedInput: 0.003, source: 'online' })
142
+ // The override is a lookup key, not a fallback chain: the other vendor's price is never used.
143
+ expect(guarded.input).not.toBe(0.65)
144
+ })
145
+
146
+ it('degrades to unpriced when the snapshot cannot be read', async () => {
147
+ const resolver = new PriceResolver({ catalogDir: () => undefined, fetch: (async () => { throw new Error('offline') }) as never })
148
+ expect(await resolver.resolve('p', 'm', { manual, online: true })).toEqual({ input: 0, output: 0, cachedInput: 0, source: 'none' })
149
+ const invalid = new PriceResolver({ catalogDir: () => undefined, fetch: (async () => new Response('<html>nope</html>')) as never })
150
+ expect(await invalid.resolve('p', 'm', { manual, online: true })).toEqual({ input: 0, output: 0, cachedInput: 0, source: 'none' })
151
+ })
152
+
153
+ it('single-flights concurrent online lookups and expires both tables on the TTL', async () => {
154
+ let clock = 1_000
155
+ const fetcher = vi.fn(async () => {
156
+ await new Promise(resolve => setTimeout(resolve, 5))
157
+ return new Response(JSON.stringify({ p: { models: { m: { cost: { input: 1, output: 2, cache_read: 3 } } } } }))
158
+ })
159
+ const resolver = new PriceResolver({ catalogDir: () => undefined, fetch: fetcher as never, now: () => clock })
160
+ const [a, b] = await Promise.all([resolver.onlineTable(), resolver.onlineTable()])
161
+ expect(fetcher).toHaveBeenCalledTimes(1)
162
+ expect(a.get(priceKey('p', 'm'))).toEqual(b.get(priceKey('p', 'm')))
163
+
164
+ clock += PRICE_TTL_MS + 1
165
+ await resolver.resolve('p', 'm', { manual, online: true })
166
+ expect(fetcher).toHaveBeenCalledTimes(2)
167
+ })
168
+
169
+ it('uses the documented public snapshot URL', async () => {
170
+ const fetcher = vi.fn(async () => new Response('{}'))
171
+ await new PriceResolver({ catalogDir: () => undefined, fetch: fetcher as never }).onlineTable()
172
+ expect(fetcher.mock.calls[0]?.[0]).toBe(MODELS_DEV_URL)
173
+ })
174
+ })