dsh-fingerprint 0.3.1 → 0.5.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 CHANGED
@@ -16,10 +16,12 @@ dsh-fingerprint 把 DSH 里已经配置好的 `provider/model` 路由当作审
16
16
 
17
17
  插件不保存 provider API key;认证和请求路由由 DSH 的 `llm` 服务处理。无法关闭 reasoning 的模型会被硬拦截,因为论文协议需要一次直接的单次 completion。
18
18
 
19
+ 这是一个手动使用的插件:正常操作都在 Web 面板里完成,默认不向 agent 注册工具,因此不占用模型上下文。需要让 agent 驱动采集做调试或自动化时,在面板底部的“设置”里把 Agent 工具开关打开即可,即时生效,不需要改配置文件。
20
+
19
21
  ## 要求
20
22
 
21
- - 一个启用 Web GUI 的 DSH profile(需要 `llm`、`tools`、`webServer` 服务)。
22
- - DSH 0.2.1 系列:实测 `0.2.1-rc.2` 和 `0.2.1-rc.4`,`engines.dsh` 限定 `>=0.2.1-rc.2 <0.2.2-0`。
23
+ - 一个启用 Web GUI 的 DSH profile(需要 `llm`、`webServer` 服务;开启 agent 工具时还需要 `tools`)。
24
+ - DSH 0.2.1 系列:安装、面板、API 与端到端采集已在 `0.2.1-rc.2` 和 `0.2.1-rc.4` 上验证;面板的 Agent 工具开关和 settings API 在 `0.2.1-rc.5` 上实测。`engines.dsh` 限定 `>=0.2.1-rc.2 <0.2.2-0`。
23
25
  - 至少一个配置好的、能关闭 reasoning(`off` / `none`)或本身不推理的 provider/model。
24
26
  - Node.js(由 DSH 自带,无需单独安装)。
25
27
 
@@ -48,24 +50,13 @@ dsh plugin --profile web add github:Jerry2262/dsh-fingerprint
48
50
  ## 使用
49
51
 
50
52
  1. 选择服务商和模型。不能关闭 reasoning 的模型会置灰、不可选,并标注原因。
51
- 2. 设置每个 cell 的重复次数(默认 30,UI 最低 10),点“开始采集”。
52
- 3. 运行中的记录显示进度;完成后显示有效率、分半 JSD、纳入 cell 数。插件被卸载或进程重启时,未完成的记录会标为失败(保留已抓到的响应),可以直接删除,不会自动续跑。
53
- 4. 在“对比记录”里选两条记录,点“对比记录”。比较方式只有一种,结果会标注两边是否同一目标。
54
- 5. 结果包含差异分数、判定线、结论、关系、cell 级差异和警告。低于判定线只说明差异小,不等于 100% 是同一个模型。
55
- 6. 行内垃圾桶可以删除记录;删除前会二次确认。运行中的记录不能删除。
56
-
57
- ## Agent 工具
58
-
59
- Host 服务同时暴露给 agent:
60
-
61
- - `llm_fingerprint_list_models`
62
- - `llm_fingerprint_probe`
63
- - `llm_fingerprint_capture`
64
- - `llm_fingerprint_get_record`
65
- - `llm_fingerprint_compare`
66
- - `llm_fingerprint_delete_record`
67
-
68
- UI 和工具调用同一个 Host 服务,不维护第二套逻辑。
53
+ 2. 点“检测可行性”。它用 2 个 cell × 3 次(6 个请求)实际发一次,确认这条路由能给出单词答案且不带 reasoning trace;通过后“开始采集”才可用,换服务商或模型会要求重新检测。模型元数据说支持 `off` 不代表服务商真的照做,这一步是唯一能提前发现的地方。
54
+ 3. 设置每个 cell 的重复次数(默认 30,UI 最低 10),点“开始采集”。
55
+ 4. 运行中的记录显示进度;完成后显示有效率、分半 JSD、纳入 cell 数。插件被卸载或进程重启时,未完成的记录会标为失败(保留已抓到的响应),可以直接删除,不会自动续跑。
56
+ 5. 在“对比记录”里选两条记录,点“对比记录”。比较方式只有一种,结果会标注两边是否同一目标。
57
+ 6. 结果包含差异分数、判定线、结论、关系、cell 级差异和警告。低于判定线只说明差异小,不等于 100% 是同一个模型。
58
+ 7. 行内垃圾桶可以删除记录;删除前会二次确认。运行中的记录不能删除。
59
+ 8. 面板底部的“设置”里有 Agent 工具开关,默认关闭。开启后把 6 个 `llm_fingerprint_*` 工具暴露给 agent(每个请求多约 4.3 KB 工具 schema),关闭后立即从后续请求里移除;选择保存在数据目录的 `settings.json`,删掉这个文件即回到默认关闭。
69
60
 
70
61
  ## 工作原理
71
62
 
@@ -79,6 +70,7 @@ UI 和工具调用同一个 Host 服务,不维护第二套逻辑。
79
70
 
80
71
  - 插件不保存 API key;key 留在 DSH 的 provider/credential 层。
81
72
  - 原始响应和指纹默认写到 `$DSH_HOME/llm-fingerprint/`,也可以由插件 row 的 `dataDir` 覆盖。
73
+ - 面板设置(目前只有 Agent 工具开关)存在同一数据目录的 `settings.json`。
82
74
  - 删除记录会删除该目录下的 `meta.json`、`responses.jsonl` 和 `fingerprint.json`。
83
75
  - 无法关闭 reasoning 的模型不会被静默采集。
84
76
 
@@ -93,14 +85,14 @@ UI 和工具调用同一个 Host 服务,不维护第二套逻辑。
93
85
 
94
86
  ## 开发
95
87
 
96
- - `index.js`:Host plugin,`llm` / `webServer` service、agent tools、HTTP API。
88
+ - `index.js`:Host plugin,`llm` / `webServer` service、HTTP API;面板开关开启时另注册 6 个 agent 工具。
97
89
  - `core.js`:协议常量、提示词 battery、归一化、JSD、指纹与对比。
98
90
  - `client.js`:Web 面板(`main` + `sidebar.panellist`)、详情抽屉、对比与删除确认。
99
91
  - `prompts.json`:论文 paper-1 的 10 个任务 × 4 种语言。
100
92
  - `locale/`:插件元数据和 UI 文案。
101
93
  - `docs/DESIGN.md`:设计细节。
102
94
  - `npm run check`:JS 语法检查(无构建步骤)。
103
- - `npm test`:核心回归测试(判定线、覆盖门槛、协议拒绝、重复 cell 与主要答案)。
95
+ - `npm test`:核心与插件注册回归测试(判定线、覆盖门槛、协议拒绝、重复 cell、主要答案,以及 agent 工具默认关闭/开关注册)。
104
96
 
105
97
  ## 许可与来源
106
98
 
package/client.js CHANGED
@@ -64,6 +64,12 @@ window.__ModuleLoader__.load({
64
64
  noCompatibleModels: 'This provider has no model that can disable reasoning.',
65
65
  noCompatibleModelsShort: 'No models eligible for capture',
66
66
  offAvailable: 'Reasoning will be disabled (off) for capture.',
67
+ checkTitle: 'Check feasibility',
68
+ checking: 'Checking…',
69
+ checkHint: 'Sends 2 cells × 3 samples (6 requests) to confirm this route answers in one word without a reasoning trace.',
70
+ checkRequired: 'Run the feasibility check first: it confirms the route satisfies the protocol in 6 requests instead of discovering it after the full battery.',
71
+ checkPassed: 'Feasibility check passed: {valid}/{responses} one-word answers, {leak} with a reasoning trace.',
72
+ checkFailed: 'Feasibility check failed: {valid}/{responses} one-word answers, {leak} with a reasoning trace, {errors} failed requests.',
67
73
  effort: 'Reasoning effort',
68
74
  started: 'Capture started:',
69
75
  error: 'Error',
@@ -120,6 +126,11 @@ window.__ModuleLoader__.load({
120
126
  referenceValidity: 'valid rate 97.6% overall, per-model median 99.6%',
121
127
  referenceSplitHalf: 'split-half JSD 0.140 for the same model, 0.463 for different models',
122
128
  referenceScore: 'The cutoff is 0.280, the 1st percentile of different-model distances: in 100 different models, about 1 would still look the same.',
129
+ settingsTitle: 'Settings',
130
+ toolsTitle: 'Agent tools',
131
+ toolsHint: 'Expose the six capture and compare tools to agents. Adds about 4.3 KB of tool schema to every request; useful for debugging or automation.',
132
+ toolsUnavailable: 'This profile does not provide the tools service, so the switch cannot be turned on.',
133
+ toolsUpdated: 'Agent tool visibility updated.',
123
134
  };
124
135
 
125
136
  const zh = {
@@ -178,6 +189,12 @@ window.__ModuleLoader__.load({
178
189
  noCompatibleModels: '该服务商没有支持关闭推理的模型,不能用于论文协议。',
179
190
  noCompatibleModelsShort: '无符合采集条件的模型',
180
191
  offAvailable: '采集时将关闭推理(off)。',
192
+ checkTitle: '检测可行性',
193
+ checking: '检测中…',
194
+ checkHint: '先用 2 个 cell × 3 次(6 个请求)确认这条路由能给出单词答案,且不带 reasoning trace。',
195
+ checkRequired: '请先检测可行性:用 6 个请求确认这条路由满足论文协议,避免跑完 1200 个请求才发现结果无效。',
196
+ checkPassed: '可行性检测通过:{valid}/{responses} 个单词答案,{leak} 个带 reasoning trace。',
197
+ checkFailed: '可行性检测未通过:{valid}/{responses} 个单词答案,{leak} 个带 reasoning trace,{errors} 个请求失败。',
181
198
  effort: '推理档位',
182
199
  started: '指纹采集已开始:',
183
200
  error: '错误',
@@ -234,6 +251,11 @@ window.__ModuleLoader__.load({
234
251
  referenceValidity: '有效率总体 97.6%,模型内中位 99.6%',
235
252
  referenceSplitHalf: '同一模型的分半差异通常 0.140,不同模型通常 0.463',
236
253
  referenceScore: '判定线是 0.280,取自不同模型距离的 1% 分位:100 个不同模型里,大约只有 1 个会被看成相同。',
254
+ settingsTitle: '设置',
255
+ toolsTitle: 'Agent 工具',
256
+ toolsHint: '把 6 个采集、对比工具暴露给 agent。每个请求会多约 4.3 KB 工具 schema,适合调试或自动化。',
257
+ toolsUnavailable: '当前 profile 没有 tools 服务,无法开启这个开关。',
258
+ toolsUpdated: 'agent 工具开关已更新。',
237
259
  };
238
260
 
239
261
  async function api(path, init) {
@@ -299,6 +321,16 @@ window.__ModuleLoader__.load({
299
321
  return text;
300
322
  }
301
323
 
324
+ /** One line describing the last feasibility check for the selected target. */
325
+ function checkMessage(check, passed, t) {
326
+ const fill = (text) => text
327
+ .replace('{valid}', String(check.valid))
328
+ .replace('{responses}', String(check.responses))
329
+ .replace('{leak}', String(check.reasoningLeak))
330
+ .replace('{errors}', String(check.errors));
331
+ return check.error ? `${fill(t('checkFailed'))} ${check.error}` : fill(t(passed ? 'checkPassed' : 'checkFailed'));
332
+ }
333
+
302
334
  function friendlyError(message, t) {
303
335
  const text = String(message ?? '');
304
336
  if (/cannot disable reasoning/i.test(text)) return t('errorNoReasoning');
@@ -307,6 +339,7 @@ window.__ModuleLoader__.load({
307
339
  if (/still running/i.test(text)) return t('errorStillRunning');
308
340
  if (/unknown fingerprint record/i.test(text)) return t('errorRecordNotFound');
309
341
  if (/no completed fingerprint|two record ids|record id is required/i.test(text)) return t('errorRecordNeedsFingerprint');
342
+ if (/tools service is unavailable/i.test(text)) return t('toolsUnavailable');
310
343
  return text;
311
344
  }
312
345
 
@@ -336,7 +369,7 @@ window.__ModuleLoader__.load({
336
369
  function visibleWarnings(comparison) {
337
370
  return (comparison.warnings || []).filter((item) => {
338
371
  if (comparison.verdictReason === 'partial_battery' && /full battery/i.test(item)) return false;
339
- if (comparison.verdictReason === 'repetitions' && /threshold needs 30/i.test(item)) return false;
372
+ if (comparison.verdictReason === 'repetitions' && /cutoff needs 30/i.test(item)) return false;
340
373
  return true;
341
374
  });
342
375
  }
@@ -444,6 +477,39 @@ window.__ModuleLoader__.load({
444
477
  fontSize: '11px',
445
478
  cursor: 'pointer',
446
479
  },
480
+ settingRow: {
481
+ display: 'flex',
482
+ alignItems: 'center',
483
+ justifyContent: 'space-between',
484
+ gap: '20px',
485
+ },
486
+ switch: {
487
+ position: 'relative',
488
+ flex: 'none',
489
+ width: '38px',
490
+ height: '22px',
491
+ padding: 0,
492
+ borderRadius: '999px',
493
+ border: '1px solid var(--dsw-alias-border-l2)',
494
+ background: 'var(--dsw-alias-bg-layer-2)',
495
+ cursor: 'pointer',
496
+ transition: 'background .15s ease, border-color .15s ease',
497
+ },
498
+ switchOn: {
499
+ background: 'var(--dsw-alias-brand-primary)',
500
+ borderColor: 'var(--dsw-alias-brand-primary)',
501
+ },
502
+ switchKnob: {
503
+ position: 'absolute',
504
+ top: '2px',
505
+ left: '2px',
506
+ width: '16px',
507
+ height: '16px',
508
+ borderRadius: '50%',
509
+ background: '#fff',
510
+ boxShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
511
+ transition: 'transform .15s ease',
512
+ },
447
513
  iconButton: {
448
514
  display: 'inline-flex',
449
515
  alignItems: 'center',
@@ -619,6 +685,48 @@ window.__ModuleLoader__.load({
619
685
  );
620
686
  }
621
687
 
688
+ /**
689
+ * Sidebar glyph: the fingerprint mark drawn to the same optical spec as the
690
+ * built-in panel icons — 16 viewBox, 1.33 stroke, currentColor, artwork in
691
+ * the 1.3–14.7 box — so it matches their visual size. The sidebar row owns
692
+ * the edge and passes it as `size`.
693
+ */
694
+ function PanelIcon({ size }) {
695
+ return h('svg', {
696
+ viewBox: '0 0 16 16',
697
+ width: size,
698
+ height: size,
699
+ fill: 'none',
700
+ 'aria-hidden': true,
701
+ strokeWidth: 1.33,
702
+ style: { display: 'block', flex: 'none' },
703
+ },
704
+ h('rect', {
705
+ x: 1.97,
706
+ y: 1.97,
707
+ width: 12.06,
708
+ height: 12.06,
709
+ rx: 3.1,
710
+ stroke: 'currentColor',
711
+ }),
712
+ h('path', {
713
+ d: 'M4.84 9.57c0-1.83 1.15-3.15 3.16-3.15s3.16 1.32 3.16 3.15',
714
+ stroke: 'currentColor',
715
+ strokeLinecap: 'round',
716
+ }),
717
+ h('path', {
718
+ d: 'M6.27 10.35c0-1.05.58-1.83 1.73-1.83s1.73.78 1.73 1.83',
719
+ stroke: 'currentColor',
720
+ strokeLinecap: 'round',
721
+ }),
722
+ h('path', {
723
+ d: 'M8 11.4v-1.3',
724
+ stroke: 'currentColor',
725
+ strokeLinecap: 'round',
726
+ }),
727
+ );
728
+ }
729
+
622
730
  function TrashIcon({ size = 14 }) {
623
731
  return h('svg', {
624
732
  viewBox: '0 0 24 24',
@@ -693,6 +801,8 @@ window.__ModuleLoader__.load({
693
801
  const [modelId, setModelId] = useState('');
694
802
  const [reps, setReps] = useState(30);
695
803
  const [starting, setStarting] = useState(false);
804
+ const [checking, setChecking] = useState(false);
805
+ const [check, setCheck] = useState(null);
696
806
  const [comparing, setComparing] = useState(false);
697
807
  const [deleting, setDeleting] = useState(false);
698
808
  const [pendingDelete, setPendingDelete] = useState(null);
@@ -707,6 +817,8 @@ window.__ModuleLoader__.load({
707
817
  const [detail, setDetail] = useState(null);
708
818
  const [detailId, setDetailId] = useState('');
709
819
  const [compact, setCompact] = useState(false);
820
+ const [toolsSetting, setToolsSetting] = useState(null);
821
+ const [savingTools, setSavingTools] = useState(false);
710
822
  const resultRef = useRef(null);
711
823
  const pageRef = useRef(null);
712
824
 
@@ -738,8 +850,13 @@ window.__ModuleLoader__.load({
738
850
  let cancelled = false;
739
851
  (async () => {
740
852
  try {
741
- const [targetData, checkpointData] = await Promise.all([api('/targets'), api('/checkpoints')]);
853
+ const [targetData, checkpointData, settingsData] = await Promise.all([
854
+ api('/targets'),
855
+ api('/checkpoints'),
856
+ api('/settings').catch(() => null),
857
+ ]);
742
858
  if (cancelled) return;
859
+ if (settingsData) setToolsSetting(settingsData.tools || null);
743
860
  const providers = (targetData.providers || []).filter((entry) => (entry.models || []).length > 0);
744
861
  setTargets(providers);
745
862
  setCheckpoints(checkpointData.checkpoints || []);
@@ -788,6 +905,9 @@ window.__ModuleLoader__.load({
788
905
  const hasIncompatible = models.some((entry) => entry.paperCompatible === false);
789
906
  const noCompatibleModels = models.length > 0 && compatibleModels.length === 0;
790
907
  const modelCompatible = model ? model.paperCompatible !== false : false;
908
+ /** A feasibility check only counts for the target it ran against. */
909
+ const checkMatches = Boolean(check && check.provider === providerId && check.model === modelId);
910
+ const checkPassed = checkMatches && check.passed === true;
791
911
  const completed = checkpoints.filter((entry) => entry.status === 'completed' || entry.status === 'completed_with_failures');
792
912
  const bOptions = completed.filter((entry) => entry.id !== a);
793
913
 
@@ -799,10 +919,54 @@ window.__ModuleLoader__.load({
799
919
  setModelId(nextModel ? nextModel.id : '');
800
920
  setWarning('');
801
921
  setNotice('');
922
+ setCheck(null);
923
+ }
924
+
925
+ async function runCheck() {
926
+ if (!providerId || !modelId || !modelCompatible) return;
927
+ setChecking(true);
928
+ setError('');
929
+ setWarning('');
930
+ setNotice('');
931
+ try {
932
+ const result = await api('/probe', {
933
+ method: 'POST',
934
+ body: JSON.stringify({ provider: providerId, model: modelId }),
935
+ });
936
+ const summary = result.summary || {};
937
+ setCheck({
938
+ provider: providerId,
939
+ model: modelId,
940
+ passed: summary.protocolCompliant === true,
941
+ responses: Number(summary.responses ?? 0),
942
+ valid: Number(summary.valid ?? 0),
943
+ reasoningLeak: Number(summary.reasoningLeak ?? 0),
944
+ errors: Number(summary.errors ?? 0),
945
+ probeId: result.probeId ?? null,
946
+ });
947
+ } catch (cause) {
948
+ setCheck({
949
+ provider: providerId,
950
+ model: modelId,
951
+ passed: false,
952
+ responses: 0,
953
+ valid: 0,
954
+ reasoningLeak: 0,
955
+ errors: 1,
956
+ error: friendlyError(cause.message || cause, t),
957
+ probeId: null,
958
+ });
959
+ } finally {
960
+ setChecking(false);
961
+ }
802
962
  }
803
963
 
804
964
  async function start() {
805
965
  if (!providerId || !modelId) return;
966
+ if (!checkPassed) {
967
+ setError(t('checkRequired'));
968
+ return;
969
+ }
806
970
  if (!modelCompatible) {
807
971
  setError(noCompatibleModels ? t('noCompatibleModels') : t('noOff'));
808
972
  return;
@@ -814,7 +978,12 @@ window.__ModuleLoader__.load({
814
978
  try {
815
979
  const created = await api('/checkpoints', {
816
980
  method: 'POST',
817
- body: JSON.stringify({ provider: providerId, model: modelId, reps: Number(reps) }),
981
+ body: JSON.stringify({
982
+ provider: providerId,
983
+ model: modelId,
984
+ reps: Number(reps),
985
+ ...(checkPassed && check.probeId ? { probeId: check.probeId } : {}),
986
+ }),
818
987
  });
819
988
  const recordId = created.recordId ?? created.checkpointId;
820
989
  setNotice(`${t('started')} ${recordId}`);
@@ -894,6 +1063,25 @@ window.__ModuleLoader__.load({
894
1063
  }
895
1064
  }
896
1065
 
1066
+ async function toggleTools() {
1067
+ if (!toolsSetting || savingTools) return;
1068
+ setSavingTools(true);
1069
+ setError('');
1070
+ setNotice('');
1071
+ try {
1072
+ const data = await api('/settings/tools', {
1073
+ method: 'POST',
1074
+ body: JSON.stringify({ enabled: !toolsSetting.enabled }),
1075
+ });
1076
+ setToolsSetting(data.tools);
1077
+ setNotice(t('toolsUpdated'));
1078
+ } catch (cause) {
1079
+ setError(friendlyError(cause.message || cause, t));
1080
+ } finally {
1081
+ setSavingTools(false);
1082
+ }
1083
+ }
1084
+
897
1085
  const estimate = 40 * Math.max(10, Math.min(100, Number(reps) || 30));
898
1086
 
899
1087
  return h('div', { style: S.page },
@@ -934,7 +1122,10 @@ window.__ModuleLoader__.load({
934
1122
  h('select', {
935
1123
  style: S.input,
936
1124
  value: modelId,
937
- onChange: (event) => setModelId(event.target.value),
1125
+ onChange: (event) => {
1126
+ setModelId(event.target.value);
1127
+ setCheck(null);
1128
+ },
938
1129
  disabled: models.length === 0 || noCompatibleModels,
939
1130
  },
940
1131
  h('option', { value: '' }, models.length === 0 ? t('noModels') : noCompatibleModels ? t('noCompatibleModelsShort') : '—'),
@@ -958,11 +1149,19 @@ window.__ModuleLoader__.load({
958
1149
  onChange: (event) => setReps(Math.max(10, Math.min(100, Number(event.target.value) || 30))),
959
1150
  }),
960
1151
  ),
961
- h('button', {
962
- style: { ...S.button, ...(starting || !modelCompatible ? S.disabled : {}) },
963
- disabled: starting || !providerId || !modelId || !modelCompatible,
964
- onClick: start,
965
- }, starting ? t('starting') : t('start')),
1152
+ h('div', { style: { display: 'flex', gap: '8px', flex: '1 1 auto', justifyContent: 'flex-end' } },
1153
+ h('button', {
1154
+ style: { ...S.buttonSecondary, ...(checking || !modelCompatible || !providerId || !modelId ? S.disabled : {}) },
1155
+ disabled: checking || starting || !providerId || !modelId || !modelCompatible,
1156
+ onClick: runCheck,
1157
+ }, checking ? t('checking') : t('checkTitle')),
1158
+ h('button', {
1159
+ style: { ...S.button, ...(starting || !modelCompatible || !checkPassed ? S.disabled : {}) },
1160
+ disabled: starting || !providerId || !modelId || !modelCompatible || !checkPassed,
1161
+ title: checkPassed ? undefined : t('checkRequired'),
1162
+ onClick: start,
1163
+ }, starting ? t('starting') : t('start')),
1164
+ ),
966
1165
  ),
967
1166
  h('div', { style: { ...S.hint, marginTop: '10px' } },
968
1167
  `${t('estimated')}: ${estimate} ${t('requests')} (40 ${t('cellsUnit')} × ${reps})`,
@@ -982,6 +1181,16 @@ window.__ModuleLoader__.load({
982
1181
  ? h('div', { style: { ...S.hint, color: 'var(--dsw-alias-state-warn-primary)' } }, t('noCompatibleModels'))
983
1182
  : null,
984
1183
  hasIncompatible ? h('div', { style: S.hint }, t('incompatibleHint')) : null,
1184
+ model
1185
+ ? h('div', {
1186
+ style: {
1187
+ ...S.hint,
1188
+ color: checkMatches
1189
+ ? (checkPassed ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)')
1190
+ : 'var(--dsw-alias-label-secondary)',
1191
+ },
1192
+ }, checkMatches ? checkMessage(check, checkPassed, t) : t('checkHint'))
1193
+ : null,
985
1194
  ),
986
1195
 
987
1196
  h(Section, {
@@ -1144,6 +1353,36 @@ window.__ModuleLoader__.load({
1144
1353
  : null,
1145
1354
  ) : null,
1146
1355
  ),
1356
+
1357
+ h(Section, { title: t('settingsTitle') },
1358
+ h('div', { style: S.settingRow },
1359
+ h('div', { style: { minWidth: 0 } },
1360
+ h('div', { style: { fontSize: '13px', fontWeight: 600 } }, t('toolsTitle')),
1361
+ h('p', { style: { ...S.hint, margin: '4px 0 0' } }, t('toolsHint')),
1362
+ toolsSetting && !toolsSetting.available
1363
+ ? h('p', { style: { ...S.hint, margin: '4px 0 0', color: 'var(--dsw-alias-state-warn-primary)' } }, t('toolsUnavailable'))
1364
+ : null,
1365
+ ),
1366
+ h('button', {
1367
+ type: 'button',
1368
+ role: 'switch',
1369
+ 'aria-checked': Boolean(toolsSetting && toolsSetting.enabled),
1370
+ 'aria-label': t('toolsTitle'),
1371
+ disabled: !toolsSetting || savingTools || !toolsSetting.available,
1372
+ style: {
1373
+ ...S.switch,
1374
+ ...(toolsSetting && toolsSetting.enabled ? S.switchOn : {}),
1375
+ ...(!toolsSetting || savingTools || !toolsSetting.available ? S.disabled : {}),
1376
+ },
1377
+ onClick: toggleTools,
1378
+ }, h('span', {
1379
+ style: {
1380
+ ...S.switchKnob,
1381
+ transform: toolsSetting && toolsSetting.enabled ? 'translateX(16px)' : 'none',
1382
+ },
1383
+ })),
1384
+ ),
1385
+ ),
1147
1386
  ),
1148
1387
  detail ? h('div', {
1149
1388
  style: S.drawerOverlay,
@@ -1276,7 +1515,7 @@ window.__ModuleLoader__.load({
1276
1515
  order: 30,
1277
1516
  locale: NS,
1278
1517
  label: () => t('title'),
1279
- }, ({ label }) => h(Mark, { size: 18 })));
1518
+ }, PanelIcon));
1280
1519
  },
1281
1520
  };
1282
1521
  },
package/docs/DESIGN.md CHANGED
@@ -7,14 +7,15 @@
7
7
  - **审计目标**:DSH 已配置的一条 provider 路由 + 一个 model id。插件不维护自己的端点表,也不保存 API key。
8
8
  - **指纹记录**:对某个目标在某一时刻做一次完整采集的结果。记录本身就是指纹,包含 40 个 cell 的经验答案分布和派生统计。
9
9
  - **对比**:任意两条记录之间的同一个操作。跨模型、跨服务商、同一目标的前后两次,都是同一个比较操作,只有 `relation` 标签不同。
10
- - **单一实现**:Host service 是唯一操作层;agent 工具和 Web 面板都是调用方。
10
+ - **单一实现**:Host service 是唯一操作层;Web 面板通过 HTTP API 调用,可选的 agent 工具调用同一组方法。
11
+ - **默认手动**:插件默认不向 agent 注册工具,所有操作在面板里完成,因此不占用模型上下文;面板“设置”里的开关打开时才注册 6 个 agent 工具,选择持久化在数据目录的 `settings.json`。
11
12
 
12
13
  ## 2. 协议常量
13
14
 
14
15
  论文:Bruckner, *One Token Is Enough*,arXiv:2607.10252。
15
16
 
16
17
  - 10 个任务 × 4 种语言(en / ru / zh / ar)= 40 个 cell。
17
- - 每个 cell 默认 30 次重复;UI 最低 10,API/tool 允许更低用于诊断。
18
+ - 每个 cell 默认 30 次重复;UI 最低 10,HTTP API 和可选的 agent 工具允许更低用于诊断。
18
19
  - `temperature=1.0`,`max_tokens=16`,每个 cell 使用对应语言的固定单词 system prompt。
19
20
  - reasoning 必须关闭;不能关闭的模型不进入采集。
20
21
  - 答案归一化:Unicode NFC、去标点/引号、case fold、阿拉伯-印度数字和中文数字映射、取首个词;每条响应归为 `valid` / `invalid` / `refusal` / `empty`,带 reasoning trace 的标为 `post_reasoning` 并从有效分布中排除。
@@ -36,6 +37,7 @@ llm-fingerprint/
36
37
  responses.jsonl # 每条成功请求一行,append-only
37
38
  fingerprint.json # 派生分布、valid rate、分半一致性、cell 明细
38
39
  probes/<probe-id>.json
40
+ settings.json # 面板设置(目前只有 agent 工具开关)
39
41
  ```
40
42
 
41
43
  - 记录 id 形如 `cp-20261020T034450-59c80896`,服务端用严格格式校验,防止路径穿越。
@@ -48,18 +50,19 @@ llm-fingerprint/
48
50
 
49
51
  ## 4. Host 结构
50
52
 
51
- `index.js` 通过 `apply(ctx, config)` 注册:
53
+ `index.js` 通过 `apply(ctx, config)` 注册一个 HTTP API,prefix `/llm-fingerprint/api`,供 Web 面板调用:
52
54
 
53
- - 六个 agent 工具:`llm_fingerprint_list_models`、`probe`、`capture`、`get_record`、`compare`、`delete_record`。
54
- - 一个 HTTP API,prefix `/llm-fingerprint/api`,供 Web 面板调用:
55
- - `GET /targets`
56
- - `GET /checkpoints`
57
- - `POST /checkpoints`
58
- - `GET /checkpoints/:id`
59
- - `DELETE /checkpoints/:id`
60
- - `POST /compare`
61
- - `POST /probe`
62
- - 所有操作都在 `createService(ctx, root)` 返回的同一组方法里实现。
55
+ - `GET /targets`
56
+ - `GET /checkpoints`
57
+ - `POST /checkpoints`
58
+ - `GET /checkpoints/:id`
59
+ - `DELETE /checkpoints/:id`
60
+ - `POST /compare`
61
+ - `POST /probe`
62
+ - `GET /settings`
63
+ - `POST /settings/tools`
64
+
65
+ 所有操作都在 `createService(ctx, root)` 返回的同一组方法里实现。面板“设置”里的 Agent 工具开关调用 `POST /settings/tools`:Host 按需通过 `ctx.inject(['tools'], ...)` 注册 6 个 agent 工具(`llm_fingerprint_list_models` / `probe` / `capture` / `get_record` / `compare` / `delete_record`),关闭时 dispose 该子 fiber 立即注销;默认关闭,因此不要求 profile 提供 `tools` 服务。开关选择持久化在数据目录的 `settings.json`,插件重载后恢复。
63
66
 
64
67
  目标发现:
65
68
 
@@ -75,6 +78,14 @@ llm-fingerprint/
75
78
 
76
79
  遇到 `MissingSessionID` 时停止调度后续采样,等待已经发出的并发请求结束后将记录标为失败,保留原始错误,不继续发完整批次。
77
80
 
81
+ ### 采集前的可行性检测
82
+
83
+ 面板选完服务商和模型后必须先点“检测可行性”:它调用 `POST /probe`(2 个 cell × 3 次,共 6 个请求),只有返回的 `summary.protocolCompliant` 为真时“开始采集”才可用;换服务商或模型会清掉结果,要求重新检测。
84
+
85
+ 原因是元数据里的 `off` 只说明适配器愿意请求关闭档位,不保证服务商真的照做。路由把 `off` 翻成“不发送 reasoning 参数”时,服务商可能仍按默认推理,`max_tokens=16` 全被推理吃光,整批响应都会变成 `post_reasoning`:`command-goat` 的 `openai-completions` 路由就这样跑完 1200 个请求才暴露 0 有效。这种失败只有在真的发一次请求后才看得见,所以检测放在采集之前,用 6 个请求代替 1200 个。
86
+
87
+ 检测只拦面板入口:HTTP API 和 agent 工具仍然可以直接 `POST /checkpoints`(`startCheckpoint` 只做元数据层面的 `supportsPaperProtocol` 检查),这条路径留给诊断和自动化。通过检测后再采集时,面板把 `probeId` 一并提交,记录到 `meta.probeId`,事后可以把记录和当次检测对上。检测日志按时间保留最近 `PROBE_LOG_RETENTION`(20)条,其余在写入时删除。
88
+
78
89
  ## 5. 兼容性硬拦截
79
90
 
80
91
  `supportsPaperProtocol(info)` 的规则:
@@ -87,7 +98,7 @@ llm-fingerprint/
87
98
 
88
99
  - UI 模型下拉里仍然列出,但 `disabled`,文本标注“不可关闭推理”;
89
100
  - 服务商下所有模型都不可用时,服务商选项标注“无可用模型”,模型选择禁用,Start 禁用,并显示原因;
90
- - Host `startCheckpoint` 再检查一次,API 和 agent 工具都无法绕过;
101
+ - Host `startCheckpoint` 再检查一次,HTTP API 和 agent 工具都无法绕过;
91
102
  - `probe` 仍然可以用于诊断,但不会创建记录。
92
103
 
93
104
  这套判断完全由运行时配置/适配器元数据计算,不维护模型名黑名单。给某个模型在 DSH 配置里加上 `off` 映射后,它会自动变为可用,不需要改插件代码。
@@ -124,11 +135,12 @@ llm-fingerprint/
124
135
 
125
136
  ## 8. 限制与不做的事
126
137
 
127
- - 兼容范围:`engines.dsh` 限定 DSH 0.2.1 系列(`>=0.2.1-rc.2 <0.2.2-0`);已在 rc.2 和 rc.4 验证安装、面板、API 与一次端到端采集。0.2.2 及以后需重新测试再放宽。
138
+ - 兼容范围:`engines.dsh` 限定 DSH 0.2.1 系列(`>=0.2.1-rc.2 <0.2.2-0`);安装、面板、API 与一次端到端采集已在 rc.2 和 rc.4 验证,面板 Agent 工具开关与 settings API 在 rc.5 上实测。0.2.2 及以后需重新测试再放宽。
128
139
  - 不做 post-reasoning 指纹模式;只能 reasoning 的模型被拒绝。
129
140
  - 不读取/保存 provider key;认证由 DSH 适配器负责。
130
141
  - 不读取 provider 返回的 model 字符串或聚合器上游路由;`ctx.llm.stream` 不暴露这些。
131
142
  - 不把“分布不一致”解释成“供应商故意掺水”;它只是可观察行为差异。
143
+ - agent 工具默认关闭,由面板“设置”里的开关控制;开启后 6 个工具定义会进入每个请求的上下文(实测约 4.3 KB / 1.2K tokens),这是调试或自动化的成本,不是面板的依赖。
132
144
  - 不实现自动定时巡检、多端点距离矩阵、CSV 导出、自定义 battery 编辑器;这些是后续功能。
133
145
 
134
146
  ## 9. 维护
@@ -136,4 +148,5 @@ llm-fingerprint/
136
148
  - 提示词版本写在 `prompts.json` 的 `version` 和每条记录的 `promptVersion`;改提示词需要重新采集,旧记录不能直接和新记录混比。
137
149
  - 判定线常量在 `core.js` 的 `CUTOFF`;修改时需要同步 README/DESIGN 并说明校准来源。
138
150
  - 数据目录 append-only;分析产物可以删除后从 `responses.jsonl` 重新计算。
139
- - 代码无构建步骤;`npm run check` 做三个 JS 文件的语法检查,`npm test` 跑核心回归测试。
151
+ - 面板设置只有 agent 工具开关,写在数据目录的 `settings.json`;删除该文件即恢复默认关闭,不影响记录数据。
152
+ - 代码无构建步骤;`npm run check` 做三个 JS 文件的语法检查,`npm test` 跑核心与插件注册回归测试。
Binary file
package/index.js CHANGED
@@ -6,9 +6,10 @@
6
6
  * the per-cell empirical answer distribution. Any two records compare with the
7
7
  * same operation and the same decision cutoff.
8
8
  *
9
- * The same service methods serve three callers: agent tools, the web API used
10
- * by the panel, and the panel's polling reads. There is one implementation of
11
- * every operation.
9
+ * The service methods back the HTTP API that the Web panel calls; there is one
10
+ * implementation of every operation. The panel is the normal surface; its
11
+ * settings section can optionally expose the six agent tools, which stay out of
12
+ * the model context while the toggle is off (the default).
12
13
  */
13
14
  import { randomUUID } from 'node:crypto';
14
15
  import {
@@ -38,7 +39,7 @@ import {
38
39
  topAnswerOf,
39
40
  } from './core.js';
40
41
 
41
- export const inject = ['llm', 'tools', 'webServer'];
42
+ export const inject = ['llm', 'webServer'];
42
43
 
43
44
  const CONCURRENCY = 8;
44
45
  const REQUEST_TIMEOUT_MS = 90_000;
@@ -46,6 +47,8 @@ const MAX_TOKENS = 16;
46
47
  const TEMPERATURE = 1.0;
47
48
  const DEFAULT_REPS = 30;
48
49
  const API_PREFIX = '/llm-fingerprint/api';
50
+ /** Feasibility checks are cheap and disposable: keep the newest logs only. */
51
+ const PROBE_LOG_RETENTION = 20;
49
52
 
50
53
  function resolveDataDir(config) {
51
54
  const configured = config && typeof config.dataDir === 'string' && config.dataDir.trim();
@@ -475,6 +478,18 @@ function recoverInterruptedRecords(root) {
475
478
  return recovered;
476
479
  }
477
480
 
481
+ /** Keep the probe directory bounded: probe ids start with a timestamp, so name order is age order. */
482
+ function pruneProbeLogs(dir) {
483
+ const names = readdirSync(dir).filter((name) => /^probe-.+\.json$/.test(name)).sort();
484
+ for (const name of names.slice(0, Math.max(0, names.length - PROBE_LOG_RETENTION))) {
485
+ try {
486
+ rmSync(path.join(dir, name), { force: true });
487
+ } catch {
488
+ // A log that cannot be removed is not worth failing a diagnostic check over.
489
+ }
490
+ }
491
+ }
492
+
478
493
  function checkpointSummary(meta) {
479
494
  return {
480
495
  id: meta.id,
@@ -649,6 +664,7 @@ function createService(ctx, root, lifetimeSignal) {
649
664
  summary,
650
665
  responses,
651
666
  }, null, 2)}\n`);
667
+ pruneProbeLogs(probeDir);
652
668
  return {
653
669
  probeId,
654
670
  provider: args.provider,
@@ -680,6 +696,10 @@ function createService(ctx, root, lifetimeSignal) {
680
696
  if (!cellById(cellId)) throw new Error(`unknown cell "${cellId}"`);
681
697
  }
682
698
  const reps = Math.max(1, Math.min(100, Number(args.reps ?? DEFAULT_REPS)));
699
+ const probeId = args.probeId == null ? null : String(args.probeId);
700
+ if (probeId !== null && !/^probe-[0-9A-Za-z-]+$/.test(probeId)) {
701
+ throw new Error('invalid probe id');
702
+ }
683
703
  const id = `cp-${nowUtc().replace(/[-:.]/g, '').slice(0, 15)}-${randomUUID().slice(0, 8)}`;
684
704
  const provider = await providerInfo(ctx, args.provider);
685
705
  const warnings = [];
@@ -701,6 +721,7 @@ function createService(ctx, root, lifetimeSignal) {
701
721
  reasoningEffort: effort,
702
722
  reasoningPolicy: effort == null ? 'none' : effortIsDisabled(effort) ? 'disabled' : 'least-available',
703
723
  reasoningLeakResponses: 0,
724
+ ...probeId === null ? {} : { probeId },
704
725
  modelInfo: adapterMetadata(info),
705
726
  request: { temperature: TEMPERATURE, maxTokens: MAX_TOKENS, systemPrompt: 'per-cell', sessionPolicy: 'new-per-sample-reuse-on-retry' },
706
727
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
@@ -926,6 +947,68 @@ function toolDefinitions(service) {
926
947
  ];
927
948
  }
928
949
 
950
+ const AGENT_TOOLS_SETTINGS_FILE = 'settings.json';
951
+
952
+ /** Read the persisted panel preferences; unreadable or malformed files fall back to the defaults. */
953
+ function readSettings(file) {
954
+ try {
955
+ const value = JSON.parse(readFileSync(file, 'utf8'));
956
+ return { tools: value?.tools === true };
957
+ } catch {
958
+ return { tools: false };
959
+ }
960
+ }
961
+
962
+ /** Persist the panel preferences. Write failures surface to the caller instead of being swallowed. */
963
+ function writeSettings(file, settings) {
964
+ writeFileSync(file, `${JSON.stringify({ tools: settings.tools === true }, null, 2)}\n`);
965
+ }
966
+
967
+ /**
968
+ * Own the optional agent tool surface: the six tools are registered exactly
969
+ * while the Web panel's toggle is on.
970
+ *
971
+ * The setting persists in the plugin's data directory (`settings.json`) and
972
+ * defaults to off, so a fresh install adds no tool definitions to the model
973
+ * context. Registration waits for the `tools` service through `ctx.inject`;
974
+ * turning the toggle off disposes that child fiber, which unregisters the tools
975
+ * immediately — no profile reload or DSH restart involved.
976
+ */
977
+ function createAgentTools(ctx, service, root) {
978
+ const file = path.join(root, AGENT_TOOLS_SETTINGS_FILE);
979
+ let settings = readSettings(file);
980
+ let fiber;
981
+
982
+ const status = () => ({ enabled: settings.tools, available: Boolean(ctx.get('tools')) });
983
+ const start = () => {
984
+ if (fiber !== undefined) return;
985
+ fiber = ctx.inject(['tools'], function registerFingerprintTools(toolCtx) {
986
+ for (const definition of toolDefinitions(service)) {
987
+ toolCtx.effect(() => toolCtx.tools.register(definition), `llm-fingerprint: ${definition.name}`);
988
+ }
989
+ });
990
+ };
991
+ const stop = async () => {
992
+ const current = fiber;
993
+ fiber = undefined;
994
+ if (current !== undefined) await current.dispose();
995
+ };
996
+ const setEnabled = async (enabled) => {
997
+ if (enabled === true && !ctx.get('tools')) {
998
+ throw new Error('the tools service is unavailable in this profile, so agent tools cannot be enabled');
999
+ }
1000
+ const next = { tools: enabled === true };
1001
+ writeSettings(file, next);
1002
+ settings = next;
1003
+ if (settings.tools) start();
1004
+ else await stop();
1005
+ return status();
1006
+ };
1007
+
1008
+ if (settings.tools) start();
1009
+ return { status, setEnabled };
1010
+ }
1011
+
929
1012
  function sendJson(res, status, value) {
930
1013
  if (res.headersSent) return;
931
1014
  res.statusCode = status;
@@ -970,7 +1053,7 @@ function rejectUnauthenticated(ctx, req, res) {
970
1053
  return true;
971
1054
  }
972
1055
 
973
- async function handleApi(ctx, req, res, service) {
1056
+ async function handleApi(ctx, req, res, service, agentTools) {
974
1057
  if (rejectUnauthenticated(ctx, req, res)) return;
975
1058
  if (req.method === 'OPTIONS') {
976
1059
  res.statusCode = 204;
@@ -1001,6 +1084,16 @@ async function handleApi(ctx, req, res, service) {
1001
1084
  sendJson(res, 200, await service.compare(body.a, body.b));
1002
1085
  return;
1003
1086
  }
1087
+ if (req.method === 'GET' && pathname === '/settings') {
1088
+ sendJson(res, 200, { tools: agentTools.status() });
1089
+ return;
1090
+ }
1091
+ if (req.method === 'POST' && pathname === '/settings/tools') {
1092
+ const body = await readRequestBody(req);
1093
+ if (typeof body.enabled !== 'boolean') throw new Error('enabled must be a boolean');
1094
+ sendJson(res, 200, { tools: await agentTools.setEnabled(body.enabled) });
1095
+ return;
1096
+ }
1004
1097
  const match = pathname.match(/^\/checkpoints\/([^/]+)$/);
1005
1098
  if (req.method === 'GET' && match) {
1006
1099
  sendJson(res, 200, await service.getCheckpoint(decodeURIComponent(match[1])));
@@ -1025,14 +1118,12 @@ export function apply(ctx, config = {}) {
1025
1118
  const lifetime = new AbortController();
1026
1119
  ctx.effect(() => () => { lifetime.abort(); }, 'llm-fingerprint: abort running captures');
1027
1120
  const service = createService(ctx, root, lifetime.signal);
1028
- for (const definition of toolDefinitions(service)) {
1029
- ctx.effect(() => ctx.tools.register(definition), `llm-fingerprint: ${definition.name}`);
1030
- }
1121
+ const agentTools = createAgentTools(ctx, service, root);
1031
1122
  ctx.effect(
1032
1123
  () => ctx.webServer.register({
1033
1124
  kind: 'prefix',
1034
1125
  path: API_PREFIX,
1035
- handler: (req, res) => handleApi(ctx, req, res, service),
1126
+ handler: (req, res) => handleApi(ctx, req, res, service, agentTools),
1036
1127
  }),
1037
1128
  'llm-fingerprint: http api',
1038
1129
  );
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dsh-fingerprint",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Paper-protocol fingerprint records and comparison panel for configured DSH provider/model routes.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/Jerry2262/dsh-fingerprint.git"
9
+ "url": "git+https://github.com/Jerry2262/dsh-fingerprint.git"
10
10
  },
11
11
  "homepage": "https://github.com/Jerry2262/dsh-fingerprint",
12
12
  "bugs": {
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "scripts": {
42
42
  "check": "node --check index.js && node --check core.js && node --check client.js",
43
- "test": "node --test test/core.test.js"
43
+ "test": "node --test test/core.test.js test/plugin.test.js"
44
44
  },
45
45
  "dsh": {
46
46
  "bundle": {