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/client-fields.ts
CHANGED
|
@@ -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
|
|
421
|
-
// back to `coding`,
|
|
422
|
-
//
|
|
423
|
-
|
|
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' })
|
package/src/client-i18n.ts
CHANGED
|
@@ -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',
|
package/src/client.test.ts
CHANGED
|
@@ -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
|
|
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={
|
|
1070
|
-
<
|
|
1071
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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 = (
|
|
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: (
|
|
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: (
|
|
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
|
+
})
|