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 +7 -6
- package/client.js +148 -9
- package/docs/DESIGN.md +8 -0
- package/index.js +20 -0
- package/package.json +2 -2
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.
|
|
54
|
-
3.
|
|
55
|
-
4.
|
|
56
|
-
5.
|
|
57
|
-
6.
|
|
58
|
-
7.
|
|
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' && /
|
|
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({
|
|
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) =>
|
|
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('
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
},
|
|
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.
|
|
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": {
|