dsh-llm-verifier 0.4.0 → 0.6.0
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 +133 -25
- package/lib/{auto-B5V49e-A.js → auto-DAMeKRkK.js} +775 -170
- package/lib/auto-DAMeKRkK.js.map +1 -0
- package/lib/{caller-DR_C7ueP.js → caller-BUK7OgUs.js} +168 -14
- package/lib/caller-BUK7OgUs.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +1847 -846
- package/lib/client.js.map +1 -1
- package/lib/core.js +267 -11
- package/lib/core.js.map +1 -1
- package/lib/index.js +2985 -364
- package/lib/index.js.map +1 -1
- package/lib/replay.js +290 -3
- package/lib/replay.js.map +1 -1
- package/lib/types/auto.d.ts +69 -2
- package/lib/types/auto.d.ts.map +1 -1
- package/lib/types/auto.js +216 -23
- package/lib/types/auto.js.map +1 -1
- package/lib/types/cache.d.ts +9 -0
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/cache.js +1 -1
- package/lib/types/cache.js.map +1 -1
- package/lib/types/caller.d.ts +23 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +180 -14
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-fields.d.ts +174 -0
- package/lib/types/client-fields.d.ts.map +1 -0
- package/lib/types/client-fields.js +456 -0
- package/lib/types/client-fields.js.map +1 -0
- package/lib/types/client-i18n.d.ts +255 -1
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +180 -4
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +57 -44
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +286 -43
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +36 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +21 -0
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +155 -4
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +271 -10
- package/lib/types/core.js.map +1 -1
- package/lib/types/engine.d.ts +76 -2
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +364 -94
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +906 -126
- package/lib/types/index.js.map +1 -1
- package/lib/types/process-activity.d.ts +109 -0
- package/lib/types/process-activity.d.ts.map +1 -0
- package/lib/types/process-activity.js +135 -0
- package/lib/types/process-activity.js.map +1 -0
- package/lib/types/process-selection.d.ts +572 -0
- package/lib/types/process-selection.d.ts.map +1 -0
- package/lib/types/process-selection.js +1183 -0
- package/lib/types/process-selection.js.map +1 -0
- package/lib/types/replay.d.ts +188 -0
- package/lib/types/replay.d.ts.map +1 -1
- package/lib/types/replay.js +231 -1
- package/lib/types/replay.js.map +1 -1
- package/lib/types/router.d.ts +321 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +600 -100
- package/lib/types/router.js.map +1 -1
- package/lib/types/session.d.ts.map +1 -1
- package/lib/types/session.js +5 -1
- package/lib/types/session.js.map +1 -1
- package/lib/types/statistics.d.ts +92 -0
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +116 -2
- package/lib/types/statistics.js.map +1 -1
- package/package.json +2 -2
- package/scripts/eval-replay.mjs +55 -5
- package/scripts/sync-installed-profiles.mjs +113 -0
- package/src/auto.test.ts +195 -4
- package/src/auto.ts +249 -28
- package/src/cache.ts +10 -1
- package/src/caller.test.ts +202 -3
- package/src/caller.ts +195 -13
- package/src/client-fields.test.ts +309 -0
- package/src/client-fields.ts +619 -0
- package/src/client-i18n.ts +212 -3
- package/src/client.test.ts +45 -2
- package/src/client.tsx +470 -162
- package/src/config.test.ts +37 -0
- package/src/config.ts +57 -0
- package/src/core.test.ts +210 -0
- package/src/core.ts +327 -10
- package/src/engine.test.ts +322 -2
- package/src/engine.ts +426 -105
- package/src/index.test.ts +1210 -3
- package/src/index.ts +880 -110
- package/src/parity.test.ts +50 -1
- package/src/process-activity.test.ts +91 -0
- package/src/process-activity.ts +177 -0
- package/src/process-selection.test.ts +911 -0
- package/src/process-selection.ts +1429 -0
- package/src/replay.test.ts +168 -1
- package/src/replay.ts +333 -1
- package/src/router.test.ts +725 -1
- package/src/router.ts +772 -89
- package/src/session.test.ts +34 -0
- package/src/session.ts +5 -1
- package/src/statistics.test.ts +157 -0
- package/src/statistics.ts +178 -2
- package/lib/auto-B5V49e-A.js.map +0 -1
- package/lib/caller-DR_C7ueP.js.map +0 -1
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { normalizeExtraJudges, type ExtraJudgeDraft } from './client-judges.ts'
|
|
2
|
+
import { computeJudgeCount, computeWorstCaseBudget, WORST_CASE_CRITERIA_PER_COMPARISON } from './client-i18n.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The settings form's single source of truth.
|
|
6
|
+
*
|
|
7
|
+
* The page used to hand-write 41 row blocks, which made the form impossible to
|
|
8
|
+
* reorganize: every new field had to be pasted into a 5000px column, its default
|
|
9
|
+
* restated inline, and its constraints (if any) only existed server-side in
|
|
10
|
+
* 'resolveConfig'. This module is a declarative registry: the renderer, the
|
|
11
|
+
* search box, the collapse state, the cross-field validation and the
|
|
12
|
+
* quick-configuration profiles are all driven by the same table.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure so it can be unit tested without a DOM.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Values {
|
|
18
|
+
enabled: boolean
|
|
19
|
+
captureDecisions: boolean
|
|
20
|
+
autoProcessSelection: boolean
|
|
21
|
+
autoProcessFailureContext: boolean
|
|
22
|
+
autoProcessAlternativeModel: string
|
|
23
|
+
autoProcessCandidates: number
|
|
24
|
+
autoVerifyMode: 'manual' | 'smart' | 'strict'
|
|
25
|
+
autoVerifyThreshold: number
|
|
26
|
+
autoVerifyRepeats: number
|
|
27
|
+
autoTrackRepeats: number
|
|
28
|
+
autoVerifyFinalRepeats: number
|
|
29
|
+
autoVerifyMinToolCalls: number
|
|
30
|
+
autoVerifyMaxChars: number
|
|
31
|
+
autoVerifyMaxPerTask: number
|
|
32
|
+
autoVerifyMaxPerSession: number
|
|
33
|
+
autoRouteSemantic: boolean
|
|
34
|
+
autoRouteMinConfidence: number
|
|
35
|
+
autoRouteMaxCandidates: number
|
|
36
|
+
autoRouteMaxPerTask: number
|
|
37
|
+
autoRouteMaxPerSession: number
|
|
38
|
+
autoTrackCompletionThreshold: number
|
|
39
|
+
autoRouteMaxItemChars: number
|
|
40
|
+
autoRouteMaxInputChars: number
|
|
41
|
+
autoMaxModelCallsPerTask: number
|
|
42
|
+
autoMaxModelCallsPerSession: number
|
|
43
|
+
autoVerifyTeamTasks: boolean
|
|
44
|
+
autoVerifyPlanMode: boolean
|
|
45
|
+
criteriaPreset: 'coding' | 'debug' | 'research' | 'ops' | 'writing' | 'custom'
|
|
46
|
+
criteriaFile: string
|
|
47
|
+
provider: string
|
|
48
|
+
model: string
|
|
49
|
+
reasoningEffort?: string
|
|
50
|
+
maxTokens: number
|
|
51
|
+
temperature: number
|
|
52
|
+
label?: string
|
|
53
|
+
maxConcurrency: number
|
|
54
|
+
maxRetries: number
|
|
55
|
+
retryBaseDelayMs: number
|
|
56
|
+
timeoutMs: number
|
|
57
|
+
cacheDir: string
|
|
58
|
+
cacheMaxEntries: number
|
|
59
|
+
estimatedInputUsdPerMillion: number
|
|
60
|
+
estimatedOutputUsdPerMillion: number
|
|
61
|
+
autoVerifySubagents: boolean
|
|
62
|
+
extraJudges: ExtraJudgeDraft[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Plugin defaults, i.e. what the form shows when the host reports no value.
|
|
67
|
+
*
|
|
68
|
+
* This is also the 'resolveConfig' default set, and it is the target of the
|
|
69
|
+
* per-row "restore default" action and of the 'balanced' profile. It must stay
|
|
70
|
+
* aligned with 'src/config.ts'; 'client-fields.test.ts' asserts that the defaults
|
|
71
|
+
* need no correction once provider/model are filled in.
|
|
72
|
+
*/
|
|
73
|
+
export const CONFIG_DEFAULTS: Values = {
|
|
74
|
+
enabled: true,
|
|
75
|
+
captureDecisions: true,
|
|
76
|
+
autoProcessSelection: false,
|
|
77
|
+
autoProcessFailureContext: true,
|
|
78
|
+
autoProcessAlternativeModel: '',
|
|
79
|
+
autoProcessCandidates: 2,
|
|
80
|
+
autoVerifyMode: 'smart',
|
|
81
|
+
autoVerifyThreshold: 0.65,
|
|
82
|
+
autoVerifyRepeats: 1,
|
|
83
|
+
autoTrackRepeats: 3,
|
|
84
|
+
autoVerifyFinalRepeats: 2,
|
|
85
|
+
autoVerifyMinToolCalls: 3,
|
|
86
|
+
autoVerifyMaxChars: 80000,
|
|
87
|
+
autoVerifyMaxPerTask: 2,
|
|
88
|
+
autoVerifyMaxPerSession: 8,
|
|
89
|
+
autoRouteSemantic: true,
|
|
90
|
+
autoRouteMinConfidence: 0.9,
|
|
91
|
+
autoRouteMaxCandidates: 8,
|
|
92
|
+
autoRouteMaxPerTask: 2,
|
|
93
|
+
autoRouteMaxPerSession: 8,
|
|
94
|
+
autoTrackCompletionThreshold: 0.684,
|
|
95
|
+
autoRouteMaxItemChars: 20000,
|
|
96
|
+
autoRouteMaxInputChars: 60000,
|
|
97
|
+
autoMaxModelCallsPerTask: 96,
|
|
98
|
+
autoMaxModelCallsPerSession: 240,
|
|
99
|
+
autoVerifyTeamTasks: true,
|
|
100
|
+
autoVerifyPlanMode: true,
|
|
101
|
+
criteriaPreset: 'coding',
|
|
102
|
+
criteriaFile: '',
|
|
103
|
+
provider: '',
|
|
104
|
+
model: '',
|
|
105
|
+
maxTokens: 32768,
|
|
106
|
+
temperature: 0.2,
|
|
107
|
+
maxConcurrency: 8,
|
|
108
|
+
maxRetries: 3,
|
|
109
|
+
retryBaseDelayMs: 500,
|
|
110
|
+
timeoutMs: 300000,
|
|
111
|
+
cacheDir: 'verifier',
|
|
112
|
+
cacheMaxEntries: 10000,
|
|
113
|
+
estimatedInputUsdPerMillion: 0,
|
|
114
|
+
estimatedOutputUsdPerMillion: 0,
|
|
115
|
+
autoVerifySubagents: false,
|
|
116
|
+
extraJudges: [],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type SectionId = 'tools' | 'autoVerify' | 'routing' | 'model' | 'budgets' | 'storage' | 'execution' | 'cost'
|
|
120
|
+
|
|
121
|
+
export interface SectionSpec {
|
|
122
|
+
id: SectionId
|
|
123
|
+
titleKey: string
|
|
124
|
+
/** 'advanced' sections start collapsed and are expanded by "expand all" or a search hit. */
|
|
125
|
+
tier: 'core' | 'advanced'
|
|
126
|
+
open: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Information architecture. The old page was five flat sections with 26 rows in
|
|
131
|
+
* "automatic verification"; here the policy switches stay visible and the
|
|
132
|
+
* numeric ceilings and persistence knobs move into collapsed advanced sections.
|
|
133
|
+
*/
|
|
134
|
+
export const SECTIONS: readonly SectionSpec[] = [
|
|
135
|
+
{ id: 'tools', titleKey: 'section.tools', tier: 'core', open: true },
|
|
136
|
+
{ id: 'autoVerify', titleKey: 'section.autoVerify', tier: 'core', open: true },
|
|
137
|
+
{ id: 'routing', titleKey: 'section.routing', tier: 'core', open: false },
|
|
138
|
+
{ id: 'model', titleKey: 'section.model', tier: 'core', open: true },
|
|
139
|
+
{ id: 'budgets', titleKey: 'section.budgets', tier: 'advanced', open: false },
|
|
140
|
+
{ id: 'storage', titleKey: 'section.storage', tier: 'advanced', open: false },
|
|
141
|
+
{ id: 'execution', titleKey: 'section.execution', tier: 'advanced', open: false },
|
|
142
|
+
{ id: 'cost', titleKey: 'section.cost', tier: 'advanced', open: false },
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
export type FieldKind = 'toggle' | 'number' | 'text' | 'select' | 'custom'
|
|
146
|
+
export type SelectSource = 'mode' | 'criteriaPreset' | 'provider' | 'model' | 'effort'
|
|
147
|
+
export type UnitKey = 'settings.unit.ms' | 'settings.unit.chars' | 'settings.unit.calls' | 'settings.unit.tokens'
|
|
148
|
+
|
|
149
|
+
export interface FieldSpec {
|
|
150
|
+
key: keyof Values
|
|
151
|
+
section: SectionId
|
|
152
|
+
titleKey: string
|
|
153
|
+
helpKey: string
|
|
154
|
+
/** Second help string, used by the couple of rows whose help text depends on the current value. */
|
|
155
|
+
helpKeyOff?: string
|
|
156
|
+
kind: FieldKind
|
|
157
|
+
/** Which catalog/static list the select renders; only for kind 'select'. */
|
|
158
|
+
select?: SelectSource
|
|
159
|
+
/** Numeric bounds, mirrored from 'resolveConfig'. */
|
|
160
|
+
min?: number
|
|
161
|
+
max?: number
|
|
162
|
+
integer?: boolean
|
|
163
|
+
/** Render a range control next to the number box. */
|
|
164
|
+
slider?: boolean
|
|
165
|
+
unitKey?: UnitKey
|
|
166
|
+
/** `false` for identity fields that have no meaningful default (provider/model/judges). */
|
|
167
|
+
resettable?: boolean
|
|
168
|
+
visibleWhen?: (values: Values) => boolean
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fieldOf = (key: keyof Values, section: SectionId, kind: FieldKind): Pick<FieldSpec, 'key' | 'section' | 'kind' | 'titleKey' | 'helpKey'> => ({
|
|
172
|
+
key,
|
|
173
|
+
section,
|
|
174
|
+
kind,
|
|
175
|
+
titleKey: 'field.' + String(key) + '.title',
|
|
176
|
+
helpKey: 'field.' + String(key) + '.help',
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const number = (
|
|
180
|
+
key: keyof Values,
|
|
181
|
+
section: SectionId,
|
|
182
|
+
options: Omit<FieldSpec, 'key' | 'section' | 'kind' | 'titleKey' | 'helpKey'> = {},
|
|
183
|
+
): FieldSpec => ({ ...fieldOf(key, section, 'number'), ...options })
|
|
184
|
+
|
|
185
|
+
const toggle = (key: keyof Values, section: SectionId): FieldSpec => fieldOf(key, section, 'toggle')
|
|
186
|
+
|
|
187
|
+
const text = (key: keyof Values, section: SectionId, resettable = false): FieldSpec => ({ ...fieldOf(key, section, 'text'), resettable })
|
|
188
|
+
|
|
189
|
+
const select = (key: keyof Values, section: SectionId, source: SelectSource, resettable = true): FieldSpec => ({
|
|
190
|
+
...fieldOf(key, section, 'select'),
|
|
191
|
+
select: source,
|
|
192
|
+
resettable,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/** Render order inside a section follows this array. */
|
|
196
|
+
export const FIELDS: readonly FieldSpec[] = [
|
|
197
|
+
{ ...toggle('enabled', 'tools'), helpKey: 'field.enabled.helpOn', helpKeyOff: 'field.enabled.helpOff' },
|
|
198
|
+
|
|
199
|
+
select('autoVerifyMode', 'autoVerify', 'mode'),
|
|
200
|
+
select('criteriaPreset', 'autoVerify', 'criteriaPreset'),
|
|
201
|
+
{ ...text('criteriaFile', 'autoVerify', true), visibleWhen: values => values.criteriaPreset === 'custom' },
|
|
202
|
+
number('autoVerifyThreshold', 'autoVerify', { min: 0, max: 1, slider: true }),
|
|
203
|
+
|
|
204
|
+
toggle('autoRouteSemantic', 'routing'),
|
|
205
|
+
number('autoRouteMinConfidence', 'routing', { min: 0, max: 1, slider: true }),
|
|
206
|
+
number('autoRouteMaxCandidates', 'routing', { min: 3, max: 16, integer: true }),
|
|
207
|
+
number('autoRouteMaxPerTask', 'routing', { min: 1, integer: true }),
|
|
208
|
+
number('autoRouteMaxPerSession', 'routing', { min: 1, integer: true }),
|
|
209
|
+
number('autoTrackCompletionThreshold', 'routing', { min: 0, max: 1, slider: true }),
|
|
210
|
+
number('autoTrackRepeats', 'routing', { min: 1, integer: true }),
|
|
211
|
+
toggle('autoVerifyTeamTasks', 'routing'),
|
|
212
|
+
toggle('autoVerifyPlanMode', 'routing'),
|
|
213
|
+
toggle('autoVerifySubagents', 'routing'),
|
|
214
|
+
toggle('autoProcessSelection', 'routing'),
|
|
215
|
+
toggle('autoProcessFailureContext', 'routing'),
|
|
216
|
+
text('autoProcessAlternativeModel', 'routing'),
|
|
217
|
+
number('autoProcessCandidates', 'routing', { min: 2, max: 4, integer: true }),
|
|
218
|
+
|
|
219
|
+
select('provider', 'model', 'provider', false),
|
|
220
|
+
select('model', 'model', 'model', false),
|
|
221
|
+
select('reasoningEffort', 'model', 'effort', false),
|
|
222
|
+
number('maxTokens', 'model', { min: 1, integer: true, unitKey: 'settings.unit.tokens' }),
|
|
223
|
+
number('temperature', 'model', { min: 0, max: 2 }),
|
|
224
|
+
text('label', 'model', true),
|
|
225
|
+
{ key: 'extraJudges', section: 'model', kind: 'custom', titleKey: 'field.extraJudges.title', helpKey: 'field.extraJudges.help' },
|
|
226
|
+
|
|
227
|
+
number('autoMaxModelCallsPerTask', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.calls' }),
|
|
228
|
+
number('autoMaxModelCallsPerSession', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.calls' }),
|
|
229
|
+
number('autoRouteMaxItemChars', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.chars' }),
|
|
230
|
+
number('autoRouteMaxInputChars', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.chars' }),
|
|
231
|
+
number('autoVerifyMaxChars', 'budgets', { min: 1000, integer: true, unitKey: 'settings.unit.chars' }),
|
|
232
|
+
number('autoVerifyMaxPerTask', 'budgets', { min: 1, integer: true }),
|
|
233
|
+
number('autoVerifyMaxPerSession', 'budgets', { min: 1, integer: true }),
|
|
234
|
+
number('autoVerifyMinToolCalls', 'budgets', { min: 1, integer: true }),
|
|
235
|
+
number('autoVerifyRepeats', 'budgets', { min: 1, integer: true }),
|
|
236
|
+
number('autoVerifyFinalRepeats', 'budgets', { min: 1, integer: true }),
|
|
237
|
+
|
|
238
|
+
toggle('captureDecisions', 'storage'),
|
|
239
|
+
text('cacheDir', 'storage', true),
|
|
240
|
+
number('cacheMaxEntries', 'storage', { min: 1, integer: true }),
|
|
241
|
+
|
|
242
|
+
number('maxConcurrency', 'execution', { min: 1, integer: true }),
|
|
243
|
+
number('maxRetries', 'execution', { min: 0, integer: true }),
|
|
244
|
+
number('retryBaseDelayMs', 'execution', { min: 1, integer: true, unitKey: 'settings.unit.ms' }),
|
|
245
|
+
number('timeoutMs', 'execution', { min: 1, integer: true, unitKey: 'settings.unit.ms' }),
|
|
246
|
+
|
|
247
|
+
number('estimatedInputUsdPerMillion', 'cost', { min: 0 }),
|
|
248
|
+
number('estimatedOutputUsdPerMillion', 'cost', { min: 0 }),
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
export function fieldsOfSection(section: SectionId): FieldSpec[] {
|
|
252
|
+
return FIELDS.filter(field => field.section === section)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function isFieldVisible(field: FieldSpec, values: Values): boolean {
|
|
256
|
+
return field.visibleWhen ? field.visibleWhen(values) : true
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A field the user actually changed away from the plugin default, if it is resettable. */
|
|
260
|
+
export function isFieldChanged(field: FieldSpec, values: Values): boolean {
|
|
261
|
+
if (field.resettable === false || field.kind === 'custom') return false
|
|
262
|
+
const current = values[field.key]
|
|
263
|
+
const fallback = CONFIG_DEFAULTS[field.key]
|
|
264
|
+
if (Array.isArray(current) || Array.isArray(fallback)) return JSON.stringify(current) !== JSON.stringify(fallback)
|
|
265
|
+
return current !== fallback
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function resetField<K extends keyof Values>(values: Values, key: K): Values {
|
|
269
|
+
return { ...values, [key]: CONFIG_DEFAULTS[key] }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build the form's value set from a settings namespace view.
|
|
274
|
+
*
|
|
275
|
+
* Every fallback comes from {@link CONFIG_DEFAULTS} so a fresh form and the
|
|
276
|
+
* per-row "restore default" action can never disagree about what "default"
|
|
277
|
+
* means. Only the shape handling (booleans default to their documented value,
|
|
278
|
+
* enums fall back to the safe member, empty text clears an override) lives here.
|
|
279
|
+
*/
|
|
280
|
+
export function valuesFromView(view: Record<string, unknown> | undefined): Values {
|
|
281
|
+
const v = view ?? {}
|
|
282
|
+
const numberOr = (key: keyof Values): number => {
|
|
283
|
+
const raw = v[key]
|
|
284
|
+
return raw === undefined || raw === null ? (CONFIG_DEFAULTS[key] as number) : Number(raw)
|
|
285
|
+
}
|
|
286
|
+
const mode = v.autoVerifyMode === 'manual' || v.autoVerifyMode === 'strict' ? v.autoVerifyMode : 'smart'
|
|
287
|
+
const preset =
|
|
288
|
+
v.criteriaPreset === 'debug' ||
|
|
289
|
+
v.criteriaPreset === 'research' ||
|
|
290
|
+
v.criteriaPreset === 'ops' ||
|
|
291
|
+
v.criteriaPreset === 'writing' ||
|
|
292
|
+
v.criteriaPreset === 'custom'
|
|
293
|
+
? v.criteriaPreset
|
|
294
|
+
: 'coding'
|
|
295
|
+
return {
|
|
296
|
+
enabled: v.enabled !== false,
|
|
297
|
+
captureDecisions: v.captureDecisions !== false,
|
|
298
|
+
autoProcessSelection: v.autoProcessSelection === true,
|
|
299
|
+
autoProcessFailureContext: v.autoProcessFailureContext !== false,
|
|
300
|
+
autoProcessAlternativeModel: typeof v.autoProcessAlternativeModel === 'string' ? v.autoProcessAlternativeModel.trim() : '',
|
|
301
|
+
autoProcessCandidates: numberOr('autoProcessCandidates'),
|
|
302
|
+
autoVerifyMode: mode,
|
|
303
|
+
autoVerifyThreshold: numberOr('autoVerifyThreshold'),
|
|
304
|
+
autoVerifyRepeats: numberOr('autoVerifyRepeats'),
|
|
305
|
+
autoTrackRepeats: numberOr('autoTrackRepeats'),
|
|
306
|
+
autoVerifyFinalRepeats: numberOr('autoVerifyFinalRepeats'),
|
|
307
|
+
autoVerifyMinToolCalls: numberOr('autoVerifyMinToolCalls'),
|
|
308
|
+
autoVerifyMaxChars: numberOr('autoVerifyMaxChars'),
|
|
309
|
+
autoVerifyMaxPerTask: numberOr('autoVerifyMaxPerTask'),
|
|
310
|
+
autoVerifyMaxPerSession: numberOr('autoVerifyMaxPerSession'),
|
|
311
|
+
autoRouteSemantic: v.autoRouteSemantic !== false,
|
|
312
|
+
autoRouteMinConfidence: numberOr('autoRouteMinConfidence'),
|
|
313
|
+
autoRouteMaxCandidates: numberOr('autoRouteMaxCandidates'),
|
|
314
|
+
autoRouteMaxPerTask: numberOr('autoRouteMaxPerTask'),
|
|
315
|
+
autoRouteMaxPerSession: numberOr('autoRouteMaxPerSession'),
|
|
316
|
+
autoTrackCompletionThreshold: numberOr('autoTrackCompletionThreshold'),
|
|
317
|
+
autoRouteMaxItemChars: numberOr('autoRouteMaxItemChars'),
|
|
318
|
+
autoRouteMaxInputChars: numberOr('autoRouteMaxInputChars'),
|
|
319
|
+
autoMaxModelCallsPerTask: numberOr('autoMaxModelCallsPerTask'),
|
|
320
|
+
autoMaxModelCallsPerSession: numberOr('autoMaxModelCallsPerSession'),
|
|
321
|
+
autoVerifyTeamTasks: v.autoVerifyTeamTasks !== false,
|
|
322
|
+
autoVerifyPlanMode: v.autoVerifyPlanMode !== false,
|
|
323
|
+
criteriaPreset: preset,
|
|
324
|
+
criteriaFile: typeof v.criteriaFile === 'string' ? v.criteriaFile.trim() : '',
|
|
325
|
+
provider: String(v.provider ?? ''),
|
|
326
|
+
model: String(v.model ?? ''),
|
|
327
|
+
...(typeof v.reasoningEffort === 'string' ? { reasoningEffort: v.reasoningEffort } : {}),
|
|
328
|
+
maxTokens: numberOr('maxTokens'),
|
|
329
|
+
temperature: numberOr('temperature'),
|
|
330
|
+
...(typeof v.label === 'string' && v.label.trim() ? { label: v.label.trim() } : {}),
|
|
331
|
+
maxConcurrency: numberOr('maxConcurrency'),
|
|
332
|
+
maxRetries: numberOr('maxRetries'),
|
|
333
|
+
retryBaseDelayMs: numberOr('retryBaseDelayMs'),
|
|
334
|
+
timeoutMs: numberOr('timeoutMs'),
|
|
335
|
+
cacheDir: typeof v.cacheDir === 'string' && v.cacheDir.trim() ? v.cacheDir.trim() : CONFIG_DEFAULTS.cacheDir,
|
|
336
|
+
cacheMaxEntries: numberOr('cacheMaxEntries'),
|
|
337
|
+
estimatedInputUsdPerMillion: numberOr('estimatedInputUsdPerMillion'),
|
|
338
|
+
estimatedOutputUsdPerMillion: numberOr('estimatedOutputUsdPerMillion'),
|
|
339
|
+
autoVerifySubagents: v.autoVerifySubagents === true,
|
|
340
|
+
extraJudges: normalizeExtraJudges(v.extraJudges),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse-time feedback for a numeric box the user is still typing in.
|
|
346
|
+
*
|
|
347
|
+
* The committed draft only ever holds values that already passed this check
|
|
348
|
+
* (see the page's numeric handler), so this is the only place that can explain
|
|
349
|
+
* \"1.5\" in a 0–1 field before the user blurs or hits save.
|
|
350
|
+
*/
|
|
351
|
+
export function textIssue(field: FieldSpec, raw: string): FieldIssue | null {
|
|
352
|
+
const trimmed = raw.trim()
|
|
353
|
+
if (trimmed === '') return null
|
|
354
|
+
const parsed = Number(trimmed)
|
|
355
|
+
if (!Number.isFinite(parsed)) return { key: field.key, code: 'required' }
|
|
356
|
+
if (field.integer && !Number.isSafeInteger(parsed)) return { key: field.key, code: 'integer' }
|
|
357
|
+
if (field.min !== undefined && field.max !== undefined) {
|
|
358
|
+
if (parsed < field.min || parsed > field.max) return { key: field.key, code: 'range', params: { min: field.min, max: field.max } }
|
|
359
|
+
} else if (field.min !== undefined && parsed < field.min) {
|
|
360
|
+
return { key: field.key, code: 'min', params: { min: field.min } }
|
|
361
|
+
} else if (field.max !== undefined && parsed > field.max) {
|
|
362
|
+
return { key: field.key, code: 'max', params: { max: field.max } }
|
|
363
|
+
}
|
|
364
|
+
return null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** True when a raw numeric string can be committed to the draft. */
|
|
368
|
+
export function acceptsNumber(field: FieldSpec, raw: string): boolean {
|
|
369
|
+
const trimmed = raw.trim()
|
|
370
|
+
if (trimmed === '') return false
|
|
371
|
+
return textIssue(field, trimmed) === null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Validation: a client-side mirror of 'resolveConfig' so the form can point at
|
|
376
|
+
// the offending row instead of surfacing one server-side sentence at the bottom
|
|
377
|
+
// of a 5000px page. Only rules that 'src/config.ts' really enforces are mirrored;
|
|
378
|
+
// anything else would block a save the host accepts.
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
export interface FieldIssue {
|
|
382
|
+
key: keyof Values
|
|
383
|
+
code: 'required' | 'range' | 'min' | 'max' | 'integer' | 'cacheDirRelative' | 'routeBudget'
|
|
384
|
+
params?: Record<string, string | number>
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function validateValues(values: Values): FieldIssue[] {
|
|
388
|
+
const issues: FieldIssue[] = []
|
|
389
|
+
for (const field of FIELDS) {
|
|
390
|
+
if (field.kind === 'custom' || field.kind === 'toggle') continue
|
|
391
|
+
if (!isFieldVisible(field, values)) continue
|
|
392
|
+
const raw = values[field.key]
|
|
393
|
+
if (field.kind === 'number') {
|
|
394
|
+
const value = typeof raw === 'number' ? raw : Number.NaN
|
|
395
|
+
if (!Number.isFinite(value)) {
|
|
396
|
+
issues.push({ key: field.key, code: 'required' })
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if (field.integer && !Number.isSafeInteger(value)) {
|
|
400
|
+
issues.push({ key: field.key, code: 'integer' })
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
if (field.min !== undefined && field.max !== undefined) {
|
|
404
|
+
if (value < field.min || value > field.max) {
|
|
405
|
+
issues.push({ key: field.key, code: 'range', params: { min: field.min, max: field.max } })
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
} else if (field.min !== undefined && value < field.min) {
|
|
409
|
+
issues.push({ key: field.key, code: 'min', params: { min: field.min } })
|
|
410
|
+
continue
|
|
411
|
+
} else if (field.max !== undefined && value > field.max) {
|
|
412
|
+
issues.push({ key: field.key, code: 'max', params: { max: field.max } })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
if (field.key === 'autoRouteMaxInputChars' && values.autoRouteMaxInputChars < values.autoRouteMaxItemChars * 2) {
|
|
416
|
+
issues.push({ key: field.key, code: 'routeBudget', params: { factor: 2 } })
|
|
417
|
+
}
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
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') {
|
|
424
|
+
const value = typeof raw === 'string' ? raw.trim() : ''
|
|
425
|
+
if (!value) {
|
|
426
|
+
issues.push({ key: field.key, code: 'required' })
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
if (field.key === 'cacheDir' && (/^(?:[A-Za-z]:[\\/]|[\\/]{1,2})/u.test(value) || value.split(/[\\/]+/u).includes('..'))) {
|
|
430
|
+
issues.push({ key: field.key, code: 'cacheDirRelative' })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (!values.provider.trim()) issues.push({ key: 'provider', code: 'required' })
|
|
435
|
+
if (!values.model.trim()) issues.push({ key: 'model', code: 'required' })
|
|
436
|
+
return issues
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function issueMap(issues: readonly FieldIssue[]): Map<keyof Values, FieldIssue> {
|
|
440
|
+
const map = new Map<keyof Values, FieldIssue>()
|
|
441
|
+
for (const issue of issues) if (!map.has(issue.key)) map.set(issue.key, issue)
|
|
442
|
+
return map
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 'settings.invalid.<code>' — the page renders these through tFormat. */
|
|
446
|
+
export function issueMessageKey(issue: FieldIssue): string {
|
|
447
|
+
return 'settings.invalid.' + issue.code
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Quick configuration profiles
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
export type ProfileId = 'balanced' | 'strict' | 'frugal' | 'toolsOnly'
|
|
455
|
+
|
|
456
|
+
export const PROFILE_IDS: readonly ProfileId[] = ['balanced', 'strict', 'frugal', 'toolsOnly']
|
|
457
|
+
|
|
458
|
+
export interface ProfileSpec {
|
|
459
|
+
id: ProfileId
|
|
460
|
+
titleKey: string
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export const PROFILES: readonly ProfileSpec[] = PROFILE_IDS.map(id => ({ id, titleKey: 'settings.profile.' + id }))
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The policy keys a profile owns. Identity/plumbing keys (provider, model,
|
|
467
|
+
* judges, rubric, storage, execution, prices) are deliberately NOT owned by a
|
|
468
|
+
* profile: switching to "frugal" must not silently repoint the judge or drop a
|
|
469
|
+
* rubric the user chose.
|
|
470
|
+
*/
|
|
471
|
+
const POLICY_KEYS = [
|
|
472
|
+
'autoVerifyMode',
|
|
473
|
+
'autoVerifyThreshold',
|
|
474
|
+
'autoVerifyRepeats',
|
|
475
|
+
'autoTrackRepeats',
|
|
476
|
+
'autoVerifyFinalRepeats',
|
|
477
|
+
'autoVerifyMinToolCalls',
|
|
478
|
+
'autoVerifyMaxChars',
|
|
479
|
+
'autoVerifyMaxPerTask',
|
|
480
|
+
'autoVerifyMaxPerSession',
|
|
481
|
+
'autoRouteSemantic',
|
|
482
|
+
'autoRouteMinConfidence',
|
|
483
|
+
'autoRouteMaxCandidates',
|
|
484
|
+
'autoRouteMaxPerTask',
|
|
485
|
+
'autoRouteMaxPerSession',
|
|
486
|
+
'autoTrackCompletionThreshold',
|
|
487
|
+
'autoRouteMaxItemChars',
|
|
488
|
+
'autoRouteMaxInputChars',
|
|
489
|
+
'autoMaxModelCallsPerTask',
|
|
490
|
+
'autoMaxModelCallsPerSession',
|
|
491
|
+
'autoVerifyTeamTasks',
|
|
492
|
+
'autoVerifyPlanMode',
|
|
493
|
+
'autoVerifySubagents',
|
|
494
|
+
'autoProcessSelection',
|
|
495
|
+
'autoProcessFailureContext',
|
|
496
|
+
'autoProcessCandidates',
|
|
497
|
+
] as const satisfies readonly (keyof Values)[]
|
|
498
|
+
|
|
499
|
+
function policyDefaults(values: Values): Values {
|
|
500
|
+
const next = { ...values } as Record<string, unknown>
|
|
501
|
+
for (const key of POLICY_KEYS) next[key] = CONFIG_DEFAULTS[key]
|
|
502
|
+
return next as unknown as Values
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The budget floor a full tournament needs for the given number of judges. */
|
|
506
|
+
export function recommendedBudgets(judgeCount: number, criteria = WORST_CASE_CRITERIA_PER_COMPARISON): {
|
|
507
|
+
autoMaxModelCallsPerTask: number
|
|
508
|
+
autoMaxModelCallsPerSession: number
|
|
509
|
+
} {
|
|
510
|
+
const { worstCaseTask, worstCaseSession } = computeWorstCaseBudget(Math.max(1, judgeCount), criteria)
|
|
511
|
+
return { autoMaxModelCallsPerTask: worstCaseTask, autoMaxModelCallsPerSession: worstCaseSession }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function applyProfile(values: Values, id: ProfileId, criteria = WORST_CASE_CRITERIA_PER_COMPARISON): Values {
|
|
515
|
+
const base = policyDefaults(values)
|
|
516
|
+
// Budgets are only ceilings: a profile writes the safe floor for the current
|
|
517
|
+
// judge count so the warning banner cannot fire on a coherent configuration.
|
|
518
|
+
const budgets = recommendedBudgets(computeJudgeCount(values.extraJudges.length), criteria)
|
|
519
|
+
switch (id) {
|
|
520
|
+
case 'strict':
|
|
521
|
+
return { ...base, ...budgets, autoVerifyMode: 'strict' }
|
|
522
|
+
case 'frugal':
|
|
523
|
+
return {
|
|
524
|
+
...base,
|
|
525
|
+
...budgets,
|
|
526
|
+
autoVerifyMode: 'smart',
|
|
527
|
+
autoVerifyThreshold: 0.7,
|
|
528
|
+
autoRouteMaxCandidates: 3,
|
|
529
|
+
autoRouteMaxPerTask: 1,
|
|
530
|
+
autoRouteMaxPerSession: 3,
|
|
531
|
+
autoVerifyMaxPerTask: 1,
|
|
532
|
+
autoVerifyMaxPerSession: 3,
|
|
533
|
+
autoTrackRepeats: 1,
|
|
534
|
+
autoVerifyTeamTasks: false,
|
|
535
|
+
autoVerifyPlanMode: false,
|
|
536
|
+
}
|
|
537
|
+
case 'toolsOnly':
|
|
538
|
+
return { ...base, ...budgets, autoVerifyMode: 'manual' }
|
|
539
|
+
case 'balanced':
|
|
540
|
+
default:
|
|
541
|
+
return { ...base, ...budgets }
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Which profile a draft currently *is*, or 'custom' when it matches none.
|
|
547
|
+
*
|
|
548
|
+
* The selector shows this value, so it must never claim a profile the settings
|
|
549
|
+
* no longer describe. Budgets are deliberately excluded from the comparison:
|
|
550
|
+
* they are derived (the safe floor for the current judge count), so a hand-made
|
|
551
|
+
* budget tweak should not flip an otherwise untouched policy set to "custom".
|
|
552
|
+
*/
|
|
553
|
+
export type ActiveProfile = ProfileId | 'custom'
|
|
554
|
+
|
|
555
|
+
const PROFILE_IDENTITY_KEYS: readonly (keyof Values)[] = POLICY_KEYS.filter(
|
|
556
|
+
key => key !== 'autoMaxModelCallsPerTask' && key !== 'autoMaxModelCallsPerSession',
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
export function activeProfile(values: Values, criteria = WORST_CASE_CRITERIA_PER_COMPARISON): ActiveProfile {
|
|
560
|
+
for (const id of PROFILE_IDS) {
|
|
561
|
+
const target = applyProfile(values, id, criteria)
|
|
562
|
+
if (PROFILE_IDENTITY_KEYS.every(key => values[key] === target[key])) return id
|
|
563
|
+
}
|
|
564
|
+
return 'custom'
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// Collapsed-section summaries: a collapsed header still says what it holds.
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
export type Translate = (key: string) => string | undefined
|
|
572
|
+
|
|
573
|
+
export type Format = (template: string, params?: Record<string, string | number>) => string
|
|
574
|
+
|
|
575
|
+
export function sectionSummary(id: SectionId, values: Values, t: Translate, format: Format): string {
|
|
576
|
+
const label = (key: string): string => t(key) ?? key
|
|
577
|
+
const state = values.enabled ? label('settings.summary.on') : label('settings.summary.off')
|
|
578
|
+
switch (id) {
|
|
579
|
+
case 'tools':
|
|
580
|
+
return state
|
|
581
|
+
case 'autoVerify':
|
|
582
|
+
if (values.autoVerifyMode === 'manual') return label('settings.summary.manualShort')
|
|
583
|
+
return format('settings.summary.mode', {
|
|
584
|
+
mode: label('field.autoVerifyMode.' + values.autoVerifyMode),
|
|
585
|
+
preset: label('field.criteriaPreset.' + values.criteriaPreset),
|
|
586
|
+
threshold: values.autoVerifyThreshold,
|
|
587
|
+
})
|
|
588
|
+
case 'routing':
|
|
589
|
+
return format('settings.summary.routing', {
|
|
590
|
+
state: values.autoRouteSemantic ? label('settings.summary.on') : label('settings.summary.off'),
|
|
591
|
+
task: values.autoRouteMaxPerTask,
|
|
592
|
+
session: values.autoRouteMaxPerSession,
|
|
593
|
+
})
|
|
594
|
+
case 'budgets':
|
|
595
|
+
return format('settings.summary.budgets', {
|
|
596
|
+
task: values.autoMaxModelCallsPerTask,
|
|
597
|
+
session: values.autoMaxModelCallsPerSession,
|
|
598
|
+
})
|
|
599
|
+
case 'storage':
|
|
600
|
+
return format('settings.summary.storage', {
|
|
601
|
+
state: values.captureDecisions ? label('settings.summary.on') : label('settings.summary.off'),
|
|
602
|
+
entries: values.cacheMaxEntries,
|
|
603
|
+
})
|
|
604
|
+
case 'execution':
|
|
605
|
+
return format('settings.summary.execution', { concurrency: values.maxConcurrency, timeout: values.timeoutMs })
|
|
606
|
+
case 'model':
|
|
607
|
+
case 'cost':
|
|
608
|
+
default:
|
|
609
|
+
return ''
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Every section with the fields that are currently visible (value-dependent rows included). */
|
|
614
|
+
export function renderSections(values: Values): { section: SectionSpec; fields: FieldSpec[] }[] {
|
|
615
|
+
return SECTIONS.map(section => ({
|
|
616
|
+
section,
|
|
617
|
+
fields: fieldsOfSection(section.id).filter(field => isFieldVisible(field, values)),
|
|
618
|
+
}))
|
|
619
|
+
}
|