dsh-auto-model-routing 0.4.1 → 0.4.2
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/CHANGELOG.md +9 -0
- package/README.md +8 -3
- package/README.zh.md +8 -2
- package/docs/feedback-evaluation.md +17 -0
- package/lib/client/index.d.ts +9 -1
- package/lib/client/index.js +93 -11
- package/lib/client/label.d.ts +1 -1
- package/lib/client/label.js +36 -1
- package/lib/client.js +160 -17
- package/lib/endpoints.d.ts +1 -0
- package/lib/endpoints.js +19 -0
- package/lib/feedback.d.ts +18 -0
- package/lib/feedback.js +21 -0
- package/lib/index.js +44 -10
- package/lib/policy-host.d.ts +10 -3
- package/lib/policy-host.js +65 -15
- package/lib/policy-store.d.ts +3 -0
- package/lib/policy-store.js +3 -2
- package/lib/policy-v2.d.ts +3 -2
- package/lib/policy-v2.js +7 -6
- package/lib/settings-host.js +3 -2
- package/lib/subagent-tool.js +3 -3
- package/lib/support.d.ts +25 -0
- package/lib/support.js +40 -0
- package/lib/usage-report.d.ts +19 -0
- package/lib/usage-report.js +62 -6
- package/package.json +17 -4
- package/skills/auto-model-routing-report/SKILL.md +5 -0
package/lib/client.js
CHANGED
|
@@ -140,6 +140,7 @@ window.__ModuleLoader__.load({
|
|
|
140
140
|
const [view, setView] = (0, react.useState)(void 0);
|
|
141
141
|
const [open, setOpen] = (0, react.useState)(false);
|
|
142
142
|
const [error, setError] = (0, react.useState)("");
|
|
143
|
+
const exposureTarget = (0, react.useRef)(null);
|
|
143
144
|
const alive = (0, react.useRef)(true);
|
|
144
145
|
(0, react.useEffect)(() => () => {
|
|
145
146
|
alive.current = false;
|
|
@@ -158,6 +159,44 @@ window.__ModuleLoader__.load({
|
|
|
158
159
|
sessionId,
|
|
159
160
|
messageId
|
|
160
161
|
]);
|
|
162
|
+
(0, react.useEffect)(() => {
|
|
163
|
+
const target = exposureTarget.current;
|
|
164
|
+
if (view?.policy !== "policy-v2" || !target || typeof IntersectionObserver === "undefined") return;
|
|
165
|
+
let visible = false, sent = false;
|
|
166
|
+
let timer;
|
|
167
|
+
const update = () => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
timer = void 0;
|
|
170
|
+
if (!sent && visible && document.visibilityState === "visible" && document.hasFocus()) timer = setTimeout(() => {
|
|
171
|
+
if (!visible || document.visibilityState !== "visible" || !document.hasFocus()) return;
|
|
172
|
+
sent = true;
|
|
173
|
+
rpc.call("/api", "auto-model-routing.observe", {
|
|
174
|
+
sessionId,
|
|
175
|
+
messageId
|
|
176
|
+
}).catch(() => {});
|
|
177
|
+
}, 3e3);
|
|
178
|
+
};
|
|
179
|
+
const observer = new IntersectionObserver((entries) => {
|
|
180
|
+
visible = entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= 1);
|
|
181
|
+
update();
|
|
182
|
+
}, { threshold: 1 });
|
|
183
|
+
observer.observe(target);
|
|
184
|
+
document.addEventListener("visibilitychange", update);
|
|
185
|
+
window.addEventListener("focus", update);
|
|
186
|
+
window.addEventListener("blur", update);
|
|
187
|
+
return () => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
observer.disconnect();
|
|
190
|
+
document.removeEventListener("visibilitychange", update);
|
|
191
|
+
window.removeEventListener("focus", update);
|
|
192
|
+
window.removeEventListener("blur", update);
|
|
193
|
+
};
|
|
194
|
+
}, [
|
|
195
|
+
view?.policy,
|
|
196
|
+
rpc,
|
|
197
|
+
sessionId,
|
|
198
|
+
messageId
|
|
199
|
+
]);
|
|
161
200
|
if (!view) return null;
|
|
162
201
|
const choose = (label) => {
|
|
163
202
|
setOpen(false);
|
|
@@ -181,7 +220,7 @@ window.__ModuleLoader__.load({
|
|
|
181
220
|
"aria-label": tooltipText(view),
|
|
182
221
|
onClick: () => setOpen((value) => !value)
|
|
183
222
|
}, (0, react.createElement)(ui.Icon));
|
|
184
|
-
return (0, react.createElement)("span",
|
|
223
|
+
return (0, react.createElement)("span", { ref: exposureTarget }, (0, react.createElement)("style", null, CSS$3), (0, react.createElement)(ui.Menu, {
|
|
185
224
|
open,
|
|
186
225
|
side: "top",
|
|
187
226
|
portal: true,
|
|
@@ -1083,6 +1122,7 @@ window.__ModuleLoader__.load({
|
|
|
1083
1122
|
// src/client/index.ts
|
|
1084
1123
|
const name = "dsh-auto-model-routing/client";
|
|
1085
1124
|
const inject = ["slots", "connection"];
|
|
1125
|
+
const REPO_URL = "https://github.com/prgrmrwy/dsh-auto-model-routing";
|
|
1086
1126
|
const CSS = `
|
|
1087
1127
|
.amr-root{position:relative;min-width:0;display:inline-flex}
|
|
1088
1128
|
.amr-trigger{display:flex;align-items:center;gap:4px;height:28px;padding:0 4px 0 8px;border:none;outline:none;background:0 0;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:400;line-height:20px;cursor:pointer;white-space:nowrap}
|
|
@@ -1090,6 +1130,11 @@ window.__ModuleLoader__.load({
|
|
|
1090
1130
|
.amr-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}
|
|
1091
1131
|
.amr-trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}
|
|
1092
1132
|
.amr-icon{flex:none;color:var(--dsw-alias-label-caption)}
|
|
1133
|
+
.amr-trigger[data-locked=true]{color:var(--dsw-alias-label-dimmed);cursor:default}
|
|
1134
|
+
.amr-trigger[data-locked=true]:hover{background:0 0}
|
|
1135
|
+
.amr-info{color:var(--dsw-alias-label-caption)}
|
|
1136
|
+
.amr-card a{color:var(--dsw-alias-state-business-primary);text-decoration:none;overflow-wrap:anywhere}
|
|
1137
|
+
.amr-card a:hover{text-decoration:underline}
|
|
1093
1138
|
.amr-trigger[data-disabled=true],.amr-trigger[data-disabled=true] .amr-icon{color:var(--dsw-alias-state-error-primary)}
|
|
1094
1139
|
.amr-panel{position:absolute;z-index:1100;bottom:100%;left:0;padding-bottom:6px}
|
|
1095
1140
|
.amr-card{width:280px;padding:10px 12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg,#fff);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);font-size:12px;line-height:18px}
|
|
@@ -1101,7 +1146,7 @@ window.__ModuleLoader__.load({
|
|
|
1101
1146
|
`;
|
|
1102
1147
|
function SwapIcon() {
|
|
1103
1148
|
return (0, react.createElement)("svg", {
|
|
1104
|
-
className: "amr-icon",
|
|
1149
|
+
className: "amr-icon amr-swap",
|
|
1105
1150
|
width: 14,
|
|
1106
1151
|
height: 14,
|
|
1107
1152
|
viewBox: "0 0 16 16",
|
|
@@ -1113,7 +1158,7 @@ window.__ModuleLoader__.load({
|
|
|
1113
1158
|
"aria-hidden": true
|
|
1114
1159
|
}, (0, react.createElement)("path", { d: "M3 5.5h9.5M10 3l2.5 2.5L10 8" }), (0, react.createElement)("path", { d: "M13 10.5H3.5M6 8l-2.5 2.5L6 13" }));
|
|
1115
1160
|
}
|
|
1116
|
-
function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast }) {
|
|
1161
|
+
function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
|
|
1117
1162
|
const [view, setView] = (0, react.useState)(initialView);
|
|
1118
1163
|
const [open, setOpen] = (0, react.useState)(initiallyOpen);
|
|
1119
1164
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
@@ -1152,6 +1197,32 @@ window.__ModuleLoader__.load({
|
|
|
1152
1197
|
const hide = setTimeout(() => setToast(void 0), TOAST_MS);
|
|
1153
1198
|
return () => clearTimeout(hide);
|
|
1154
1199
|
}, [toast]);
|
|
1200
|
+
const refreshRef = (0, react.useRef)(() => {});
|
|
1201
|
+
const subscription = (0, react.useRef)(void 0);
|
|
1202
|
+
const subscribeRef = (0, react.useRef)(void 0);
|
|
1203
|
+
subscribeRef.current = subscribeSelection;
|
|
1204
|
+
const ensureSubscription = () => {
|
|
1205
|
+
const subscribe = subscribeRef.current;
|
|
1206
|
+
if (!subscribe || subscription.current?.sessionId === sessionId) return;
|
|
1207
|
+
subscription.current?.stop();
|
|
1208
|
+
subscription.current = void 0;
|
|
1209
|
+
let handle;
|
|
1210
|
+
try {
|
|
1211
|
+
const stop = subscribe(sessionId, () => {
|
|
1212
|
+
if (handle) clearTimeout(handle);
|
|
1213
|
+
handle = setTimeout(() => refreshRef.current(), 80);
|
|
1214
|
+
});
|
|
1215
|
+
subscription.current = {
|
|
1216
|
+
sessionId,
|
|
1217
|
+
stop: () => {
|
|
1218
|
+
stop();
|
|
1219
|
+
if (handle) clearTimeout(handle);
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
} catch {
|
|
1223
|
+
subscription.current = void 0;
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1155
1226
|
(0, react.useEffect)(() => {
|
|
1156
1227
|
let active = true;
|
|
1157
1228
|
primed.current = false;
|
|
@@ -1164,17 +1235,22 @@ window.__ModuleLoader__.load({
|
|
|
1164
1235
|
viewSession.current = sessionId;
|
|
1165
1236
|
setView(value);
|
|
1166
1237
|
setError("");
|
|
1238
|
+
ensureSubscription();
|
|
1167
1239
|
}
|
|
1168
1240
|
}, (reason) => {
|
|
1169
1241
|
if (active) setError(String(reason));
|
|
1170
1242
|
});
|
|
1171
1243
|
};
|
|
1244
|
+
refreshRef.current = refresh;
|
|
1172
1245
|
refresh();
|
|
1173
1246
|
const poll = setInterval(refresh, 3e3);
|
|
1174
1247
|
return () => {
|
|
1175
1248
|
active = false;
|
|
1176
1249
|
clearInterval(poll);
|
|
1177
1250
|
clearTimeout(timer.current);
|
|
1251
|
+
subscription.current?.stop();
|
|
1252
|
+
subscription.current = void 0;
|
|
1253
|
+
refreshRef.current = () => {};
|
|
1178
1254
|
};
|
|
1179
1255
|
}, [rpc, sessionId]);
|
|
1180
1256
|
const on = (event) => () => {
|
|
@@ -1198,10 +1274,60 @@ window.__ModuleLoader__.load({
|
|
|
1198
1274
|
};
|
|
1199
1275
|
const off = view?.disabled;
|
|
1200
1276
|
const hint = off ? CATEGORY_HINTS[off.category] ?? off.category : void 0;
|
|
1277
|
+
const locked = view?.autoSupport?.supported === false;
|
|
1278
|
+
const support = view?.autoSupport;
|
|
1279
|
+
const lockedModel = `${support?.provider ?? "未知 provider"} / ${support?.model ?? "未知模型"}`;
|
|
1201
1280
|
const place = anchor.current ?? {
|
|
1202
1281
|
left: typeof window === "undefined" ? 0 : window.innerWidth / 2,
|
|
1203
1282
|
top: (typeof window === "undefined" ? 0 : window.innerHeight) - 96
|
|
1204
1283
|
};
|
|
1284
|
+
const panel = locked ? [
|
|
1285
|
+
(0, react.createElement)("p", { key: "locked-title" }, (0, react.createElement)("strong", null, "AUTO 不支持当前模型")),
|
|
1286
|
+
(0, react.createElement)("p", { key: "locked-model" }, `${lockedModel} 不在自动路由支持范围,已切换为 MANUAL。`),
|
|
1287
|
+
(0, react.createElement)("p", { key: "locked-note" }, "目前仅支持 Claude / GPT 系列模型;其他模型策略欢迎提交 issue 或 PR:"),
|
|
1288
|
+
(0, react.createElement)("p", { key: "locked-link" }, (0, react.createElement)("a", {
|
|
1289
|
+
href: REPO_URL,
|
|
1290
|
+
target: "_blank",
|
|
1291
|
+
rel: "noopener noreferrer"
|
|
1292
|
+
}, REPO_URL)),
|
|
1293
|
+
(0, react.createElement)("p", {
|
|
1294
|
+
key: "locked-hint",
|
|
1295
|
+
className: "amr-muted"
|
|
1296
|
+
}, "切回 Claude / GPT 系列模型后,可重新点击启用 AUTO。"),
|
|
1297
|
+
error && (0, react.createElement)("p", {
|
|
1298
|
+
key: "error",
|
|
1299
|
+
role: "alert",
|
|
1300
|
+
className: "amr-error"
|
|
1301
|
+
}, error)
|
|
1302
|
+
] : [
|
|
1303
|
+
(0, react.createElement)("p", { key: "title" }, (0, react.createElement)("strong", null, view?.mode === "AUTO" ? "自动调整推理强度" : "手动:不自动调整")),
|
|
1304
|
+
(0, react.createElement)("p", { key: "output" }, `本回合插件输出:${view?.effort ?? "未改写"} · ${view?.reason ?? "尚无决定"}`),
|
|
1305
|
+
view?.decision && (0, react.createElement)("div", {
|
|
1306
|
+
key: "latest",
|
|
1307
|
+
className: "amr-latest"
|
|
1308
|
+
}, (0, react.createElement)("p", null, (0, react.createElement)("strong", null, "最近决策")), ...toastText(view.decision).map((line, index) => (0, react.createElement)("p", {
|
|
1309
|
+
key: index,
|
|
1310
|
+
className: index ? "amr-muted" : void 0
|
|
1311
|
+
}, line))),
|
|
1312
|
+
(0, react.createElement)("p", {
|
|
1313
|
+
key: "policy",
|
|
1314
|
+
className: "amr-muted"
|
|
1315
|
+
}, view?.policy === "policy-v2" ? `AUTO · ${view.depth ?? "尚无判断"};主模型不变,推理投入按请求应用;子任务自动选配。` : "AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。"),
|
|
1316
|
+
off && (0, react.createElement)("p", {
|
|
1317
|
+
key: "disabled",
|
|
1318
|
+
role: "status",
|
|
1319
|
+
className: "amr-error"
|
|
1320
|
+
}, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`),
|
|
1321
|
+
(0, react.createElement)("p", {
|
|
1322
|
+
key: "data",
|
|
1323
|
+
className: "amr-muted"
|
|
1324
|
+
}, "AUTO 会把脱敏任务文本发送给设置中所选的决策服务判档。"),
|
|
1325
|
+
error && (0, react.createElement)("p", {
|
|
1326
|
+
key: "error",
|
|
1327
|
+
role: "alert",
|
|
1328
|
+
className: "amr-error"
|
|
1329
|
+
}, error)
|
|
1330
|
+
];
|
|
1205
1331
|
return (0, react.createElement)("span", {
|
|
1206
1332
|
ref: (el) => {
|
|
1207
1333
|
rootRef.current = el;
|
|
@@ -1217,13 +1343,19 @@ window.__ModuleLoader__.load({
|
|
|
1217
1343
|
className: "amr-trigger",
|
|
1218
1344
|
disabled: busy || !view,
|
|
1219
1345
|
onClick: () => {
|
|
1346
|
+
if (locked) return;
|
|
1220
1347
|
toggle();
|
|
1221
1348
|
},
|
|
1222
1349
|
"data-disabled": off ? "true" : void 0,
|
|
1350
|
+
"data-locked": locked ? "true" : void 0,
|
|
1351
|
+
"aria-disabled": locked ? "true" : void 0,
|
|
1223
1352
|
style: { border: "none" },
|
|
1224
|
-
title: off ? `Jev 判档已停用:${hint}。点击 AUTO 重试` : "点击切换自动推理强度",
|
|
1225
|
-
"aria-label": `推理强度模式:${view?.mode ?? "加载中"},点击切换`
|
|
1226
|
-
}, (0, react.createElement)("span", null, off ? `${view?.mode} · Jev 停用` : view?.mode ?? "…"),
|
|
1353
|
+
title: locked ? "AUTO 仅支持 Claude / GPT 系列模型;悬停查看说明" : off ? `Jev 判档已停用:${hint}。点击 AUTO 重试` : "点击切换自动推理强度",
|
|
1354
|
+
"aria-label": locked ? `推理强度模式:${view?.mode ?? "MANUAL"}(AUTO 不支持当前模型,不可切换)` : `推理强度模式:${view?.mode ?? "加载中"},点击切换`
|
|
1355
|
+
}, (0, react.createElement)("span", null, locked ? view?.mode ?? "MANUAL" : off ? `${view?.mode} · Jev 停用` : view?.mode ?? "…"), locked ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.IconInfoOutlineRegular, {
|
|
1356
|
+
size: 14,
|
|
1357
|
+
className: "amr-icon amr-info"
|
|
1358
|
+
}) : (0, react.createElement)(SwapIcon)), toast && !open && (typeof document === "undefined" ? (0, react.createElement)("div", {
|
|
1227
1359
|
className: "amr-toast",
|
|
1228
1360
|
role: "status",
|
|
1229
1361
|
style: { pointerEvents: "none" }
|
|
@@ -1244,16 +1376,7 @@ window.__ModuleLoader__.load({
|
|
|
1244
1376
|
}, line))), document.body)), open && (0, react.createElement)("section", {
|
|
1245
1377
|
className: "amr-panel",
|
|
1246
1378
|
"aria-label": "自动路由状态"
|
|
1247
|
-
}, (0, react.createElement)("div", { className: "amr-card" },
|
|
1248
|
-
key: index,
|
|
1249
|
-
className: index ? "amr-muted" : void 0
|
|
1250
|
-
}, line))), (0, react.createElement)("p", { className: "amr-muted" }, view?.policy === "policy-v2" ? `AUTO · ${view.depth ?? "尚无判断"};主模型不变,推理投入按请求应用;子任务自动选配。` : "AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。"), off && (0, react.createElement)("p", {
|
|
1251
|
-
role: "status",
|
|
1252
|
-
className: "amr-error"
|
|
1253
|
-
}, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`), (0, react.createElement)("p", { className: "amr-muted" }, "AUTO 会把脱敏任务文本发送给设置中所选的决策服务判档。"), error && (0, react.createElement)("p", {
|
|
1254
|
-
role: "alert",
|
|
1255
|
-
className: "amr-error"
|
|
1256
|
-
}, error))));
|
|
1379
|
+
}, (0, react.createElement)("div", { className: "amr-card" }, ...panel)));
|
|
1257
1380
|
}
|
|
1258
1381
|
const labelUi = {
|
|
1259
1382
|
Tooltip: _deepseek_ai_dsh_client_ui_primitives.Tooltip,
|
|
@@ -1261,6 +1384,25 @@ window.__ModuleLoader__.load({
|
|
|
1261
1384
|
Icon: _deepseek_ai_dsh_client_ui_primitives.IconGaugeOutlineRegular
|
|
1262
1385
|
};
|
|
1263
1386
|
function apply(ctx) {
|
|
1387
|
+
const pickerSubscriber = { current: void 0 };
|
|
1388
|
+
const subscribeSelection = (id, onChange) => pickerSubscriber.current ? pickerSubscriber.current(id, onChange) : () => {};
|
|
1389
|
+
try {
|
|
1390
|
+
const inject = ctx.inject;
|
|
1391
|
+
if (typeof inject === "function") inject.call(ctx, ["modelDirectories"], (scope) => {
|
|
1392
|
+
const service = scope.modelDirectories;
|
|
1393
|
+
if (!service) return;
|
|
1394
|
+
pickerSubscriber.current = (id, onChange) => {
|
|
1395
|
+
try {
|
|
1396
|
+
return service.directoryFor(id).store.subscribe(onChange);
|
|
1397
|
+
} catch {
|
|
1398
|
+
return () => {};
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
return () => {
|
|
1402
|
+
pickerSubscriber.current = void 0;
|
|
1403
|
+
};
|
|
1404
|
+
});
|
|
1405
|
+
} catch {}
|
|
1264
1406
|
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1265
1407
|
name: "settings.section",
|
|
1266
1408
|
id: "auto-model-routing",
|
|
@@ -1288,7 +1430,8 @@ window.__ModuleLoader__.load({
|
|
|
1288
1430
|
order: -10,
|
|
1289
1431
|
inject: (sessionId) => ({
|
|
1290
1432
|
sessionId,
|
|
1291
|
-
rpc: ctx.connection.rpc
|
|
1433
|
+
rpc: ctx.connection.rpc,
|
|
1434
|
+
subscribeSelection
|
|
1292
1435
|
})
|
|
1293
1436
|
}, RoutingChip));
|
|
1294
1437
|
}
|
package/lib/endpoints.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { RoutingState } from './state.js';
|
|
|
4
4
|
import { type Tier } from './effort.js';
|
|
5
5
|
import type { TurnRecord } from './judgements.js';
|
|
6
6
|
export declare function endpoint(operation: 'state' | 'setMode', body: unknown, resolveAgent: (id: string) => Promise<AgentRef | undefined>, router: Router, state: RoutingState, toastOn?: () => boolean): Promise<Response>;
|
|
7
|
+
export declare function observeEndpoint(body: unknown, resolveAgent: (id: string) => Promise<AgentRef | undefined>, book: FeedbackBook, warn: () => void): Promise<Response>;
|
|
7
8
|
export interface LabelBook {
|
|
8
9
|
lookup(sessionId: string, messageId: string): TurnRecord | undefined;
|
|
9
10
|
label(sessionId: string, turn: number, label: Tier): void;
|
package/lib/endpoints.js
CHANGED
|
@@ -15,6 +15,25 @@ export async function endpoint(operation, body, resolveAgent, router, state, toa
|
|
|
15
15
|
const decision = router.judgement(sessionId);
|
|
16
16
|
return Response.json({ ...state.read(sessionId), ...router.status(agent), ...(decision ? { decision } : {}), ...(toastOn() ? { decisionToast: true } : {}) });
|
|
17
17
|
}
|
|
18
|
+
export async function observeEndpoint(body, resolveAgent, book, warn) {
|
|
19
|
+
if (typeof body !== 'object' || body === null)
|
|
20
|
+
return Response.json({ error: 'invalid request' }, { status: 400 });
|
|
21
|
+
const { sessionId, messageId } = body;
|
|
22
|
+
if (typeof sessionId !== 'string' || !sessionId || typeof messageId !== 'string' || !messageId)
|
|
23
|
+
return Response.json({ error: 'invalid request' }, { status: 400 });
|
|
24
|
+
const agent = await resolveAgent(sessionId);
|
|
25
|
+
if (!agent || agent.origin === 'subagent')
|
|
26
|
+
return Response.json({ error: 'unknown ordinary session' }, { status: 404 });
|
|
27
|
+
const binding = book.lookup(sessionId, messageId);
|
|
28
|
+
if (binding)
|
|
29
|
+
try {
|
|
30
|
+
book.fact(sessionId, binding.decisionId, 'exposed');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
warn();
|
|
34
|
+
}
|
|
35
|
+
return Response.json({});
|
|
36
|
+
}
|
|
18
37
|
/** Query (no `label`) or label the judged turn a reply belongs to (design D13). */
|
|
19
38
|
export async function labelEndpoint(body, resolveAgent, book, feedbackBook) {
|
|
20
39
|
if (typeof body !== 'object' || body === null)
|
package/lib/feedback.d.ts
CHANGED
|
@@ -30,11 +30,24 @@ declare const schema: z.ZodObject<{
|
|
|
30
30
|
}>>>;
|
|
31
31
|
feedbackAt: z.ZodOptional<z.ZodNumber>;
|
|
32
32
|
}, z.core.$strict>;
|
|
33
|
+
declare const factEvent: z.ZodObject<{
|
|
34
|
+
type: z.ZodLiteral<"fact">;
|
|
35
|
+
version: z.ZodLiteral<1>;
|
|
36
|
+
sessionId: z.ZodString;
|
|
37
|
+
decisionId: z.ZodNumber;
|
|
38
|
+
fact: z.ZodEnum<{
|
|
39
|
+
exposed: "exposed";
|
|
40
|
+
"manual-switch": "manual-switch";
|
|
41
|
+
}>;
|
|
42
|
+
at: z.ZodNumber;
|
|
43
|
+
}, z.core.$strict>;
|
|
44
|
+
export type FeedbackFact = z.infer<typeof factEvent>['fact'];
|
|
33
45
|
export type FeedbackRecord = z.infer<typeof schema>;
|
|
34
46
|
/** Immutable decision bindings and separate feedback events; never includes task text or credentials. */
|
|
35
47
|
export declare class FeedbackBook {
|
|
36
48
|
private file;
|
|
37
49
|
private records;
|
|
50
|
+
private factRecords;
|
|
38
51
|
constructor(file: string);
|
|
39
52
|
private key;
|
|
40
53
|
lookup(sessionId: string, messageId: string): {
|
|
@@ -90,6 +103,11 @@ export declare class FeedbackBook {
|
|
|
90
103
|
}[];
|
|
91
104
|
bind(record: FeedbackRecord): void;
|
|
92
105
|
set(sessionId: string, messageId: string, feedback: typeof allocationFeedback[number] | null): void;
|
|
106
|
+
facts(sessionId: string, decisionId: number): {
|
|
107
|
+
exposed?: number;
|
|
108
|
+
"manual-switch"?: number;
|
|
109
|
+
};
|
|
110
|
+
fact(sessionId: string, decisionId: number, fact: FeedbackFact): void;
|
|
93
111
|
private append;
|
|
94
112
|
}
|
|
95
113
|
export {};
|
package/lib/feedback.js
CHANGED
|
@@ -11,10 +11,12 @@ const snapshot = z.object({
|
|
|
11
11
|
}).strict();
|
|
12
12
|
const schema = z.object({ sessionId: z.string(), messageId: z.string(), decisionId: z.number(), depth: z.string(), model: z.string(), effort: z.string(), snapshot: snapshot.optional(), boundAt: z.number().optional(), feedback: z.enum(allocationFeedback).nullable().optional(), feedbackAt: z.number().optional() }).strict();
|
|
13
13
|
const feedbackEvent = z.object({ type: z.literal('feedback'), version: z.literal(2), sessionId: z.string(), messageId: z.string(), decisionId: z.number(), at: z.number(), feedback: z.enum(allocationFeedback).nullable() }).strict();
|
|
14
|
+
const factEvent = z.object({ type: z.literal('fact'), version: z.literal(1), sessionId: z.string().min(1), decisionId: z.number(), fact: z.enum(['exposed', 'manual-switch']), at: z.number().finite() }).strict();
|
|
14
15
|
/** Immutable decision bindings and separate feedback events; never includes task text or credentials. */
|
|
15
16
|
export class FeedbackBook {
|
|
16
17
|
file;
|
|
17
18
|
records = new Map();
|
|
19
|
+
factRecords = new Map();
|
|
18
20
|
constructor(file) {
|
|
19
21
|
this.file = file;
|
|
20
22
|
let text;
|
|
@@ -29,6 +31,15 @@ export class FeedbackBook {
|
|
|
29
31
|
for (const line of text.split('\n'))
|
|
30
32
|
try {
|
|
31
33
|
const raw = JSON.parse(line);
|
|
34
|
+
const fact = factEvent.safeParse(raw);
|
|
35
|
+
if (fact.success) {
|
|
36
|
+
const e = fact.data;
|
|
37
|
+
const key = JSON.stringify([e.sessionId, e.decisionId]);
|
|
38
|
+
const facts = this.factRecords.get(key) ?? {};
|
|
39
|
+
facts[e.fact] ??= e.at;
|
|
40
|
+
this.factRecords.set(key, facts);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
32
43
|
const event = feedbackEvent.safeParse(raw);
|
|
33
44
|
if (event.success) {
|
|
34
45
|
const e = event.data;
|
|
@@ -63,5 +74,15 @@ export class FeedbackBook {
|
|
|
63
74
|
this.append(event);
|
|
64
75
|
this.records.set(this.key(sessionId, messageId), { ...r, feedback, feedbackAt: event.at });
|
|
65
76
|
}
|
|
77
|
+
facts(sessionId, decisionId) { return { ...this.factRecords.get(JSON.stringify([sessionId, decisionId])) }; }
|
|
78
|
+
fact(sessionId, decisionId, fact) {
|
|
79
|
+
const key = JSON.stringify([sessionId, decisionId]);
|
|
80
|
+
const facts = this.factRecords.get(key) ?? {};
|
|
81
|
+
if (facts[fact] !== undefined)
|
|
82
|
+
return;
|
|
83
|
+
const event = factEvent.parse({ type: 'fact', version: 1, sessionId, decisionId, fact, at: Date.now() });
|
|
84
|
+
this.append(event);
|
|
85
|
+
this.factRecords.set(key, { ...facts, [fact]: event.at });
|
|
86
|
+
}
|
|
66
87
|
append(record) { mkdirSync(dirname(this.file), { recursive: true }); appendFileSync(this.file, JSON.stringify(record) + '\n', { mode: 0o600 }); }
|
|
67
88
|
}
|
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { settingsHost } from './settings-host.js';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { FileStateStore, RoutingState } from './state.js';
|
|
6
6
|
import { Router } from './router.js';
|
|
7
|
-
import { endpoint, labelEndpoint } from './endpoints.js';
|
|
7
|
+
import { endpoint, labelEndpoint, observeEndpoint } from './endpoints.js';
|
|
8
8
|
import { FeedbackBook } from './feedback.js';
|
|
9
9
|
import { JudgementBook } from './judgements.js';
|
|
10
10
|
import { userInput } from './input.js';
|
|
@@ -13,6 +13,7 @@ import { routeSubagentTool } from './subagent-tool.js';
|
|
|
13
13
|
import { buildState, previousTurn } from './context.js';
|
|
14
14
|
import { policyHost } from './policy-host.js';
|
|
15
15
|
import { PolicyStore } from './policy-store.js';
|
|
16
|
+
import { currentMainSelection, mainSupported } from './support.js';
|
|
16
17
|
export const name = 'dsh-auto-model-routing';
|
|
17
18
|
export const inject = ['llm', 'agents', 'tools'];
|
|
18
19
|
const knownEfforts = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
@@ -36,15 +37,22 @@ export function apply(ctx, config = {}) {
|
|
|
36
37
|
}
|
|
37
38
|
}, () => settings.read().settings);
|
|
38
39
|
const feedback = new FeedbackBook(resolve(home, 'state/auto-model-routing/allocation-feedback.jsonl'));
|
|
39
|
-
|
|
40
|
+
// Official writer is available only while the session controller is mounted.
|
|
41
|
+
let selectModel;
|
|
42
|
+
const policy = policyHost(ctx, { feedback, jev, state, selectModel: async (request) => { if (!selectModel)
|
|
43
|
+
throw new Error('session controller is unavailable'); await selectModel(request); }, store: new PolicyStore(resolve(home, 'state/auto-model-routing/policy-v2')), settings: () => settings.read().settings, catalog: () => hostSettings.catalog(true) });
|
|
40
44
|
const toastOn = () => settings.read().settings.decisionToast;
|
|
41
45
|
ctx.effect(() => ctx.tools.register(routeSubagentTool(jev, { agents: ctx.agents, state, readSettings: () => settings.read().settings, catalog: hostSettings.catalog, judged: (sessionId, value) => router.judged(sessionId, value) })));
|
|
42
46
|
ctx.on('agent/created', ({ agent, source }) => {
|
|
43
47
|
// Factory resume announces `resume`; a nonzero initial seed also excludes restored/forked histories.
|
|
44
48
|
if (source !== 'startup' || agent.session.header.origin === 'subagent' || agent.session.firstLiveSeq !== 0 || agent.session.header.isSeeded || agent.session.snapshotEvents().some(event => event.type === 'session/end-seed') || stateFiles.get(agent.id))
|
|
45
49
|
return;
|
|
46
|
-
if (settings.read().settings.defaultMode
|
|
47
|
-
|
|
50
|
+
if (settings.read().settings.defaultMode !== 'AUTO')
|
|
51
|
+
return;
|
|
52
|
+
// A new session whose default main model is outside AUTO's Claude/GPT scope starts in MANUAL.
|
|
53
|
+
if (!mainSupported(currentMainSelection(ctx, agent)))
|
|
54
|
+
return;
|
|
55
|
+
state.setMode(agent.id, 'AUTO');
|
|
48
56
|
return undefined;
|
|
49
57
|
});
|
|
50
58
|
// Agent is the WeakMap identity; origin stays on the immutable session header.
|
|
@@ -89,7 +97,6 @@ export function apply(ctx, config = {}) {
|
|
|
89
97
|
book.assistant(session.id, data.message.id, data.turn);
|
|
90
98
|
});
|
|
91
99
|
// Official selection writer, set while the session controller is available.
|
|
92
|
-
let selectModel;
|
|
93
100
|
const inflight = new Map();
|
|
94
101
|
ctx.on('agent/request', async (payload, next) => {
|
|
95
102
|
const config = await next();
|
|
@@ -119,7 +126,7 @@ export function apply(ctx, config = {}) {
|
|
|
119
126
|
child.effect(() => () => { selectModel = undefined; });
|
|
120
127
|
});
|
|
121
128
|
ctx.inject(['connection', 'sessionController'], child => {
|
|
122
|
-
for (const operation of ['state', 'setMode', 'label', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
|
|
129
|
+
for (const operation of ['state', 'setMode', 'label', 'observe', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
|
|
123
130
|
child.effect(() => child.connection.fetch.register({
|
|
124
131
|
path: `/api/auto-model-routing.${operation}`,
|
|
125
132
|
methods: ['POST'], requestBody: 'buffered',
|
|
@@ -139,12 +146,21 @@ export function apply(ctx, config = {}) {
|
|
|
139
146
|
const result = await child.sessionController.resolveAgent(id);
|
|
140
147
|
return 'agent' in result ? ref(result.agent) : undefined;
|
|
141
148
|
};
|
|
149
|
+
const resolveSessionAgent = async (id) => {
|
|
150
|
+
const result = await child.sessionController.resolveAgent(id);
|
|
151
|
+
return 'agent' in result ? result.agent : undefined;
|
|
152
|
+
};
|
|
142
153
|
const payload = enveloped ? wire.payload : body;
|
|
154
|
+
let manualDecision;
|
|
143
155
|
if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
|
|
144
156
|
const id = payload.sessionId;
|
|
145
157
|
const mode = payload.mode;
|
|
146
|
-
if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id))
|
|
158
|
+
if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id)) {
|
|
159
|
+
const decisionId = policy.appliedDecision(id);
|
|
160
|
+
if (mode === 'MANUAL' && decisionId !== undefined)
|
|
161
|
+
manualDecision = { sessionId: id, decisionId };
|
|
147
162
|
policy.invalidate(id);
|
|
163
|
+
}
|
|
148
164
|
}
|
|
149
165
|
const response = operation === 'childRouting'
|
|
150
166
|
? (typeof payload?.sessionId === 'string'
|
|
@@ -152,12 +168,30 @@ export function apply(ctx, config = {}) {
|
|
|
152
168
|
: Response.json({ error: 'invalid request' }, { status: 400 }))
|
|
153
169
|
: operation === 'settings' || operation === 'saveSettings' || operation === 'setKey' || operation === 'testDecision'
|
|
154
170
|
? await hostSettings.endpoint(operation, payload)
|
|
155
|
-
: operation === '
|
|
171
|
+
: operation === 'observe' ? await observeEndpoint(payload, resolveAgent, feedback, () => ctx.logger.warn('auto-model-routing: could not record exposure'))
|
|
172
|
+
: operation === 'label' ? await labelEndpoint(payload, resolveAgent, book, feedback) : await endpoint(operation, payload, resolveAgent, router, state, toastOn);
|
|
173
|
+
if (manualDecision && response.ok) {
|
|
174
|
+
try {
|
|
175
|
+
feedback.fact(manualDecision.sessionId, manualDecision.decisionId, 'manual-switch');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
ctx.logger.warn('auto-model-routing: could not record MANUAL switch');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
156
181
|
if ((operation === 'state' || operation === 'setMode') && response.ok && settings.read().settings.policy === 'policy-v2') {
|
|
182
|
+
const sessionId = payload.sessionId;
|
|
183
|
+
const agent = await resolveSessionAgent(sessionId).catch(() => undefined);
|
|
184
|
+
const selection = agent ? currentMainSelection(ctx, agent) : undefined;
|
|
185
|
+
const supported = mainSupported(selection);
|
|
186
|
+
// Leaving AUTO is persisted: a pick outside the Claude/GPT scope must not keep judging turns.
|
|
187
|
+
if (!supported && state.read(sessionId).mode === 'AUTO') {
|
|
188
|
+
state.setMode(sessionId, 'MANUAL');
|
|
189
|
+
policy.invalidate(sessionId);
|
|
190
|
+
}
|
|
157
191
|
const value = await response.json();
|
|
158
|
-
const main = policy.mainStatus(
|
|
192
|
+
const main = policy.mainStatus(sessionId);
|
|
159
193
|
const { decision: _legacyDecision, effort: _legacyEffort, reason: _legacyReason, history: _legacyHistory, ...base } = value;
|
|
160
|
-
const result = { ...base, history: [], policy: 'policy-v2', ...main };
|
|
194
|
+
const result = { ...base, mode: state.read(sessionId).mode, history: [], policy: 'policy-v2', ...main, autoSupport: { supported, ...(selection ? { provider: selection.provider, model: selection.model } : {}) } };
|
|
161
195
|
return Response.json(enveloped ? { type: 'server-response', rpcId: wire.rpcId, result: { ok: true, value: result } } : result);
|
|
162
196
|
}
|
|
163
197
|
if (!enveloped)
|
package/lib/policy-host.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ import { PolicyStore, type ChildDecision } from './policy-store.js';
|
|
|
5
5
|
import type { RoutingState } from './state.js';
|
|
6
6
|
import type { RoutingCatalog, Settings } from './settings-contract.js';
|
|
7
7
|
export declare function policyHost(ctx: Context, deps: {
|
|
8
|
+
selectModel: (request: {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
reasoningEffort: string;
|
|
13
|
+
}) => Promise<unknown>;
|
|
8
14
|
feedback: FeedbackBook;
|
|
9
15
|
jev: JevClient;
|
|
10
16
|
state: RoutingState;
|
|
@@ -12,6 +18,7 @@ export declare function policyHost(ctx: Context, deps: {
|
|
|
12
18
|
settings: () => Settings;
|
|
13
19
|
catalog: () => Promise<RoutingCatalog>;
|
|
14
20
|
}): {
|
|
21
|
+
appliedDecision: (id: string) => number | undefined;
|
|
15
22
|
invalidate: (id: string) => void;
|
|
16
23
|
status: (id: string) => {
|
|
17
24
|
version: 1;
|
|
@@ -23,7 +30,7 @@ export declare function policyHost(ctx: Context, deps: {
|
|
|
23
30
|
status: "failed" | "recommended" | "unsupported" | "needs-context" | "applied";
|
|
24
31
|
path: "ordinary" | "fork" | "teammate";
|
|
25
32
|
explicit: {
|
|
26
|
-
provider: "claude" | "codex" | "dsh-magpie-connect";
|
|
33
|
+
provider: "claude" | "codex" | "dsh-magpie-connect" | "dsh-magpie-connector";
|
|
27
34
|
model?: string | undefined;
|
|
28
35
|
reasoningEffort?: string | undefined;
|
|
29
36
|
};
|
|
@@ -47,12 +54,12 @@ export declare function policyHost(ctx: Context, deps: {
|
|
|
47
54
|
capabilityProbabilities?: Record<string, number> | undefined;
|
|
48
55
|
} | undefined;
|
|
49
56
|
route?: {
|
|
50
|
-
provider: "claude" | "codex" | "dsh-magpie-connect";
|
|
57
|
+
provider: "claude" | "codex" | "dsh-magpie-connect" | "dsh-magpie-connector";
|
|
51
58
|
model: string;
|
|
52
59
|
reasoningEffort: "low" | "medium" | "high" | "xhigh";
|
|
53
60
|
} | undefined;
|
|
54
61
|
actual?: {
|
|
55
|
-
provider: "claude" | "codex" | "dsh-magpie-connect";
|
|
62
|
+
provider: "claude" | "codex" | "dsh-magpie-connect" | "dsh-magpie-connector";
|
|
56
63
|
model: string;
|
|
57
64
|
reasoningEffort: "low" | "medium" | "high" | "xhigh";
|
|
58
65
|
} | undefined;
|