dsh-fingerprint 0.4.0 → 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
@@ -50,12 +50,13 @@ dsh plugin --profile web add github:Jerry2262/dsh-fingerprint
50
50
  ## 使用
51
51
 
52
52
  1. 选择服务商和模型。不能关闭 reasoning 的模型会置灰、不可选,并标注原因。
53
- 2. 设置每个 cell 的重复次数(默认 30,UI 最低 10),点“开始采集”。
54
- 3. 运行中的记录显示进度;完成后显示有效率、分半 JSD、纳入 cell 数。插件被卸载或进程重启时,未完成的记录会标为失败(保留已抓到的响应),可以直接删除,不会自动续跑。
55
- 4. 在“对比记录”里选两条记录,点“对比记录”。比较方式只有一种,结果会标注两边是否同一目标。
56
- 5. 结果包含差异分数、判定线、结论、关系、cell 级差异和警告。低于判定线只说明差异小,不等于 100% 是同一个模型。
57
- 6. 行内垃圾桶可以删除记录;删除前会二次确认。运行中的记录不能删除。
58
- 7. 面板底部的“设置”里有 Agent 工具开关,默认关闭。开启后把 6 个 `llm_fingerprint_*` 工具暴露给 agent(每个请求多约 4.3 KB 工具 schema),关闭后立即从后续请求里移除;选择保存在数据目录的 `settings.json`,删掉这个文件即回到默认关闭。
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`,删掉这个文件即回到默认关闭。
59
60
 
60
61
  ## 工作原理
61
62
 
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',
@@ -183,6 +189,12 @@ window.__ModuleLoader__.load({
183
189
  noCompatibleModels: '该服务商没有支持关闭推理的模型,不能用于论文协议。',
184
190
  noCompatibleModelsShort: '无符合采集条件的模型',
185
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} 个请求失败。',
186
198
  effort: '推理档位',
187
199
  started: '指纹采集已开始:',
188
200
  error: '错误',
@@ -309,6 +321,16 @@ window.__ModuleLoader__.load({
309
321
  return text;
310
322
  }
311
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
+
312
334
  function friendlyError(message, t) {
313
335
  const text = String(message ?? '');
314
336
  if (/cannot disable reasoning/i.test(text)) return t('errorNoReasoning');
@@ -347,7 +369,7 @@ window.__ModuleLoader__.load({
347
369
  function visibleWarnings(comparison) {
348
370
  return (comparison.warnings || []).filter((item) => {
349
371
  if (comparison.verdictReason === 'partial_battery' && /full battery/i.test(item)) return false;
350
- 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;
351
373
  return true;
352
374
  });
353
375
  }
@@ -663,6 +685,48 @@ window.__ModuleLoader__.load({
663
685
  );
664
686
  }
665
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
+
666
730
  function TrashIcon({ size = 14 }) {
667
731
  return h('svg', {
668
732
  viewBox: '0 0 24 24',
@@ -737,6 +801,8 @@ window.__ModuleLoader__.load({
737
801
  const [modelId, setModelId] = useState('');
738
802
  const [reps, setReps] = useState(30);
739
803
  const [starting, setStarting] = useState(false);
804
+ const [checking, setChecking] = useState(false);
805
+ const [check, setCheck] = useState(null);
740
806
  const [comparing, setComparing] = useState(false);
741
807
  const [deleting, setDeleting] = useState(false);
742
808
  const [pendingDelete, setPendingDelete] = useState(null);
@@ -839,6 +905,9 @@ window.__ModuleLoader__.load({
839
905
  const hasIncompatible = models.some((entry) => entry.paperCompatible === false);
840
906
  const noCompatibleModels = models.length > 0 && compatibleModels.length === 0;
841
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;
842
911
  const completed = checkpoints.filter((entry) => entry.status === 'completed' || entry.status === 'completed_with_failures');
843
912
  const bOptions = completed.filter((entry) => entry.id !== a);
844
913
 
@@ -850,10 +919,54 @@ window.__ModuleLoader__.load({
850
919
  setModelId(nextModel ? nextModel.id : '');
851
920
  setWarning('');
852
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
+ }
853
962
  }
854
963
 
855
964
  async function start() {
856
965
  if (!providerId || !modelId) return;
966
+ if (!checkPassed) {
967
+ setError(t('checkRequired'));
968
+ return;
969
+ }
857
970
  if (!modelCompatible) {
858
971
  setError(noCompatibleModels ? t('noCompatibleModels') : t('noOff'));
859
972
  return;
@@ -865,7 +978,12 @@ window.__ModuleLoader__.load({
865
978
  try {
866
979
  const created = await api('/checkpoints', {
867
980
  method: 'POST',
868
- 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
+ }),
869
987
  });
870
988
  const recordId = created.recordId ?? created.checkpointId;
871
989
  setNotice(`${t('started')} ${recordId}`);
@@ -1004,7 +1122,10 @@ window.__ModuleLoader__.load({
1004
1122
  h('select', {
1005
1123
  style: S.input,
1006
1124
  value: modelId,
1007
- onChange: (event) => setModelId(event.target.value),
1125
+ onChange: (event) => {
1126
+ setModelId(event.target.value);
1127
+ setCheck(null);
1128
+ },
1008
1129
  disabled: models.length === 0 || noCompatibleModels,
1009
1130
  },
1010
1131
  h('option', { value: '' }, models.length === 0 ? t('noModels') : noCompatibleModels ? t('noCompatibleModelsShort') : '—'),
@@ -1028,11 +1149,19 @@ window.__ModuleLoader__.load({
1028
1149
  onChange: (event) => setReps(Math.max(10, Math.min(100, Number(event.target.value) || 30))),
1029
1150
  }),
1030
1151
  ),
1031
- h('button', {
1032
- style: { ...S.button, ...(starting || !modelCompatible ? S.disabled : {}) },
1033
- disabled: starting || !providerId || !modelId || !modelCompatible,
1034
- onClick: start,
1035
- }, 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
+ ),
1036
1165
  ),
1037
1166
  h('div', { style: { ...S.hint, marginTop: '10px' } },
1038
1167
  `${t('estimated')}: ${estimate} ${t('requests')} (40 ${t('cellsUnit')} × ${reps})`,
@@ -1052,6 +1181,16 @@ window.__ModuleLoader__.load({
1052
1181
  ? h('div', { style: { ...S.hint, color: 'var(--dsw-alias-state-warn-primary)' } }, t('noCompatibleModels'))
1053
1182
  : null,
1054
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,
1055
1194
  ),
1056
1195
 
1057
1196
  h(Section, {
@@ -1376,7 +1515,7 @@ window.__ModuleLoader__.load({
1376
1515
  order: 30,
1377
1516
  locale: NS,
1378
1517
  label: () => t('title'),
1379
- }, ({ label }) => h(Mark, { size: 18 })));
1518
+ }, PanelIcon));
1380
1519
  },
1381
1520
  };
1382
1521
  },
package/docs/DESIGN.md CHANGED
@@ -78,6 +78,14 @@ llm-fingerprint/
78
78
 
79
79
  遇到 `MissingSessionID` 时停止调度后续采样,等待已经发出的并发请求结束后将记录标为失败,保留原始错误,不继续发完整批次。
80
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
+
81
89
  ## 5. 兼容性硬拦截
82
90
 
83
91
  `supportsPaperProtocol(info)` 的规则:
package/index.js CHANGED
@@ -47,6 +47,8 @@ const MAX_TOKENS = 16;
47
47
  const TEMPERATURE = 1.0;
48
48
  const DEFAULT_REPS = 30;
49
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;
50
52
 
51
53
  function resolveDataDir(config) {
52
54
  const configured = config && typeof config.dataDir === 'string' && config.dataDir.trim();
@@ -476,6 +478,18 @@ function recoverInterruptedRecords(root) {
476
478
  return recovered;
477
479
  }
478
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
+
479
493
  function checkpointSummary(meta) {
480
494
  return {
481
495
  id: meta.id,
@@ -650,6 +664,7 @@ function createService(ctx, root, lifetimeSignal) {
650
664
  summary,
651
665
  responses,
652
666
  }, null, 2)}\n`);
667
+ pruneProbeLogs(probeDir);
653
668
  return {
654
669
  probeId,
655
670
  provider: args.provider,
@@ -681,6 +696,10 @@ function createService(ctx, root, lifetimeSignal) {
681
696
  if (!cellById(cellId)) throw new Error(`unknown cell "${cellId}"`);
682
697
  }
683
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
+ }
684
703
  const id = `cp-${nowUtc().replace(/[-:.]/g, '').slice(0, 15)}-${randomUUID().slice(0, 8)}`;
685
704
  const provider = await providerInfo(ctx, args.provider);
686
705
  const warnings = [];
@@ -702,6 +721,7 @@ function createService(ctx, root, lifetimeSignal) {
702
721
  reasoningEffort: effort,
703
722
  reasoningPolicy: effort == null ? 'none' : effortIsDisabled(effort) ? 'disabled' : 'least-available',
704
723
  reasoningLeakResponses: 0,
724
+ ...probeId === null ? {} : { probeId },
705
725
  modelInfo: adapterMetadata(info),
706
726
  request: { temperature: TEMPERATURE, maxTokens: MAX_TOKENS, systemPrompt: 'per-cell', sessionPolicy: 'new-per-sample-reuse-on-retry' },
707
727
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dsh-fingerprint",
3
- "version": "0.4.0",
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": {