dsh-plugin-model-tuning 2.0.1 → 2.1.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.
Files changed (5) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +62 -1
  3. package/client.js +119 -11
  4. package/index.js +94 -9
  5. package/package.json +1 -1
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 dsh-plugin contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-plugin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -20,6 +20,9 @@
20
20
  - 系统提示角色兼容开关:`自动` / `system` / `developer`(仅 `llm-pi-ai`,
21
21
  写入模型条目的 `compat.supportsDeveloperRole`;遇到 kimi/DeepSeek 等
22
22
  报 `role 'developer' is not allowed` 时选 `system`);
23
+ - 思考格式兼容开关:`自动` / `自适应 thinking`(仅 `llm-pi-ai` 且路由 `api: anthropic-messages`,
24
+ 写入模型条目的 `compat.forceAdaptiveThinking`;见下节「Anthropic 思考格式」。
25
+ 该模型一个推理档位都没勾选时不显示——那时请求里根本没有 `thinking`);
23
26
  - 生图模型勾选(把该模型写入「绘图」页签的模型列表,见「绘图页签」);
24
27
  - 顶部搜索框按模型名 / ID / 描述 / provider 过滤;
25
28
  - 修改即时保存(`remote.settings.mutate`;模型目录来自 `remote.session.modelCatalog()`)。
@@ -136,6 +139,59 @@ harness 的请求模态是**闭合集合 `text | image`**:`dsh-llm` 的 `Model
136
139
  > 「系统提示角色」开关依赖 DSH 适配层 `dsh-llm-pi-ai` 透传 `compat.supportsDeveloperRole`
137
140
  >(`0.1.0-rc.6` 默认不透传,需按本仓库 `deepseek-harness` 的 catalog.ts 补丁同步,或等上游合入)。
138
141
 
142
+ ## Anthropic 思考格式(自适应 vs 预算式)
143
+
144
+ 「模型参数」页的**思考格式**开关解决的是这一条报错:
145
+
146
+ ```
147
+ 400 {"error":{"message":"claude-opus-5-5 requires adaptive thinking; omit thinking or use
148
+ thinking.type=adaptive and output_config.effort","type":"invalid_request_error"}}
149
+ ```
150
+
151
+ - **原因**:pi-ai 只有在模型的 `compat.forceAdaptiveThinking === true` 时才发
152
+ `thinking: {type:"adaptive"}` + `output_config: {effort:"…"}`;否则发旧的预算式
153
+ `thinking: {type:"enabled", budget_tokens:N}`。只接受自适应思考的模型(Claude Opus 4.6 及更新、
154
+ Sonnet 5、Fable 5、Kimi K3 等)会直接 400 拒掉预算式请求。
155
+ - **为什么手写路由会中招**:pi-ai 内置目录给它自己带的模型 ID 标了这个标记,
156
+ 而 `dsh-llm-pi-ai` 只在「模型 ID 命中该 provider 自己的目录条目」时才继承目录 `compat`
157
+ (`resolveModelCompat` 要求 `base.api === api`)。手写路由(例如 `providers.ironman-claude`
158
+ 这种目录里不存在的 key)没有任何目录条目可继承,`claude-opus-5-5` 这类目录里其实认识、
159
+ 但挂在 `anthropic` 名下的 ID 也一样继承不到——于是请求退化成预算式。
160
+ - **怎么修**:在该模型的「思考格式」里选**自适应 thinking**,页面把
161
+ `compat.forceAdaptiveThinking: true` 写进模型条目(与 `系统提示角色` 同一套写法),
162
+ 下一次请求即生效,无需重启。Claude 4.5 及更早只认预算式,保持「自动」。
163
+ - **另一条路(不动插件)**:把这条路由改成目录里已有的 key(例如 `providers.anthropic`
164
+ 只覆盖 `baseURL` / `apiKeyEnv`,用 `models:` 收窄到要用的几个 ID),模型 ID 因此命中目录条目,
165
+ 自适应标记、`supportsTemperature` 等兼容位都会自动继承。代价是 provider ID 变了,
166
+ 老会话里记录的路由名会对不上,所以通常仍是逐个模型打勾更省事。
167
+
168
+ > **已知边界**:思考强度选 `off` 时,`dsh-llm-pi-ai` 会把声明里 `off: null` 的那一项**移出**
169
+ > `thinkingLevelMap`,pi-ai 因此发 `thinking: {type:"disabled"}`(只有把 `off` 钉成 `null` 才会
170
+ > 整个省略该参数——那是内置目录条目的写法),本开关管不到这一段。若网关连 `disabled` 也拒绝,
171
+ > 把该模型的「可选推理档位」全部取消(写成 `reasoningEfforts: false`,即视为无推理能力),
172
+ > 请求里就完全不会出现 `thinking`。
173
+
174
+ > **耦合点**:字段名 `forceAdaptiveThinking` 与「只在该协议的白名单内」都由上游决定
175
+ > ——`dsh-llm-pi-ai` 的 `anthropic-messages` compat 门(`0.1.7-alpha.2` 起已含该字段,
176
+ > `884f7b9c41` 于 2026-08-18 引入)会拒收白名单外的字段,pi-ai 的
177
+ > `dist/api/anthropic-messages.js` 是唯一读它的地方。`tests/adaptive-thinking.test.mjs`
178
+ > 在有 `DSH_TEST_DEPLOY_ROOT` 时直接问该部署的 pi-ai 要请求体,上游改名会在这里失败而不是在会话里变成 400。
179
+
180
+ ## dsh 0.2.1(alpha)适配(v2.1)
181
+
182
+ - **标题 helper 换了 API。** 0.2.1-alpha 系列删掉了 `generateSessionTitleWithLlm`,改成
183
+ `executeSessionTitleLlm(ctx, config, request, providerId, prepared)`:提示词、输入包装、推理档位选择
184
+ 与输出解析都交还给提供方。本插件按宿主实际导出择一:有新 API 就走新 API(提示词与解析规则复刻官方
185
+ `session-title-first-prompt-llm`,推理档位取路由最低档),否则回落旧 helper,0.1.7–0.2.0 不受影响。
186
+ 2.0.x 在 0.2.1 上会因为找不到旧导出而整行激活失败(绘图与设置页一起没了)。
187
+ - **标题注册失败不再拖垮整个插件。** `apply` 里标题路由单独兜底:失败只打一条 warn、不注册标题提供方,
188
+ 绘图工具与设置页照常。
189
+ - **与官方「会话标题刷新」(可选目录里的 `session-titles-bundle`)互斥。** 两者都要独占
190
+ `ctx.sessionTitle.register()`,后注册的一方失败——本插件这边表现为上面那条 warn。二选一。
191
+ - **推理档位与官方 Models 页写的是同一个字段。** 0.2.1-alpha.4 起官方 Models 页可以给手工声明的 pi-ai
192
+ 模型编辑 `reasoningEfforts`,并允许每一档发送自定义 API 值(如 `high → deep`)。本页勾选档位时保留
193
+ 已存的值,只有新加的档位才默认用档位名;`off` 的已存值也原样保留。
194
+
139
195
  ## 与 DSH 版本的耦合点(0.1.7-alpha.2)
140
196
 
141
197
  - **provider 目录必须取 `llm.listConfigurableProviders()`**(返回 `LlmConfigurableProvider`
@@ -155,15 +211,20 @@ node --test tests/title-route.test.mjs # 标题路由解析与 Config
155
211
  node --test tests/title-purpose.test.mjs # 用途页读写规划 + locale 回归
156
212
  node --test tests/image-link.test.mjs # 生图链接写入规划(纯函数)
157
213
  node --test tests/image.test.mjs # 绘图传输/工具/Remote(本地 HTTP,不外联)
214
+ node --test tests/adaptive-thinking.test.mjs # 思考格式开关:白名单 + 写入规划(+ 部署耦合守卫)
158
215
  DSH_TEST_DEPLOY_ROOT=<隔离部署> node --test tests/title-provider.test.mjs # 真 helper + 假 ctx
159
216
  ```
160
217
 
161
218
  - `title-provider.test.mjs` 用**假 ctx + 真共享策略**(只 mock `ctx.llm.stream`)验证唯一注册、
162
219
  继承/指定路由、首条消息选择、辅助请求记录、失败与取消路径。`tests/_host.mjs` 要求
163
220
  `DSH_TEST_DEPLOY_ROOT` 指向隔离的 0.1.7-alpha.2 部署。
221
+ - `adaptive-thinking.test.mjs` 的前半段是纯函数(开关只出现在 `anthropic-messages` 路由、
222
+ 写入落在模型条目的 `compat` 上,目录路由落到 `modelOverrides`);后半段只在有
223
+ `DSH_TEST_DEPLOY_ROOT` 时运行,把该部署的 pi-ai 拦在假 `fetch` 前,直接读出请求体,
224
+ 证明「打勾 → `thinking.type=adaptive` + `output_config.effort`,不打勾 → 预算式」。
164
225
  - **这些测试不是实机验证**。实机验收:标题从会话日志读 `session/title-llm-request` 的 `route`
165
226
  与最终 `session/title` 的 `source.model`;绘图确认「模型参数」勾选 → 绘图页出现条目 →
166
- 直接绘图面板出图。
227
+ 直接绘图面板出图;思考格式在模型下拉里选一档后看会话日志里那次请求的请求体。
167
228
 
168
229
  ## 安装 / 卸载
169
230
 
package/client.js CHANGED
@@ -142,6 +142,27 @@ window.__ModuleLoader__.load({
142
142
  const MODALITY_CHOICES = ["auto", "text", "text-image"];
143
143
  const MODALITY_LABELS = { auto: "自动", text: "纯文本", "text-image": "文本 + 图像" };
144
144
 
145
+ /**
146
+ * The compat switch that selects Anthropic's adaptive thinking request.
147
+ *
148
+ * pi-ai sends `thinking: {type:"adaptive"}` plus `output_config.effort` only
149
+ * for a model whose `compat.forceAdaptiveThinking` is true; without it the
150
+ * request carries the legacy budget form (`thinking.type="enabled"` with
151
+ * `budget_tokens`). A model that requires adaptive thinking answers that
152
+ * legacy request with HTTP 400 — "requires adaptive thinking; omit thinking
153
+ * or use thinking.type=adaptive and output_config.effort". The installed
154
+ * catalog states the flag for the model ids it ships; a route this profile
155
+ * declares by hand has no catalog entry to inherit it from, so the switch
156
+ * has to be written on the model entry here.
157
+ */
158
+ const ADAPTIVE_THINKING_COMPAT_KEY = "forceAdaptiveThinking";
159
+ /**
160
+ * The one wire protocol whose compat takes that switch. `llm-pi-ai` refuses
161
+ * a compat field the model's resolved api does not offer, so the control is
162
+ * shown only on a route whose own configuration names this protocol.
163
+ */
164
+ const ADAPTIVE_THINKING_API = "anthropic-messages";
165
+
145
166
  function formatCapacity(n) {
146
167
  if (n == null || typeof n !== "number" || !Number.isFinite(n)) return "";
147
168
  if (n % 1000000 === 0) return String(n / 1000000) + "M";
@@ -327,6 +348,25 @@ window.__ModuleLoader__.load({
327
348
  return undefined;
328
349
  }
329
350
 
351
+ /**
352
+ * The compat key this page may write for one route, or null when the route
353
+ * cannot take it.
354
+ *
355
+ * The route's own `api` is the only place the protocol is known before a
356
+ * model resolves: a hand-declared route spells it out, while a catalog route
357
+ * omits it and resolves each model's protocol from the installed catalog.
358
+ * The control therefore appears exactly where the switch is both needed (no
359
+ * catalog entry to inherit the flag from) and accepted.
360
+ * @param settingsNs - the provider's settings namespace.
361
+ * @param profile - the route's own settings section.
362
+ * @returns the adaptive-thinking compat key, or null.
363
+ */
364
+ function adaptiveThinkingCompatKey(settingsNs, profile) {
365
+ if (settingsNs !== "llm-pi-ai") return null;
366
+ if (!profile || typeof profile !== "object") return null;
367
+ return profile.api === ADAPTIVE_THINKING_API ? ADAPTIVE_THINKING_COMPAT_KEY : null;
368
+ }
369
+
330
370
  function computeWriteOps(entry, nsView, modelId, field, value) {
331
371
  const ns = entry.settingsNs;
332
372
  const settingsPath = entry.settingsPath || [];
@@ -365,6 +405,44 @@ window.__ModuleLoader__.load({
365
405
  return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [{ id: modelId, [field]: value }] }] };
366
406
  }
367
407
 
408
+ /**
409
+ * The stored `reasoningEfforts` of one model: its `models[]` row first,
410
+ * then (pi-ai) its `modelOverrides` entry; undefined when neither declares it.
411
+ */
412
+ function storedReasoningEfforts(entry, nsView, modelId) {
413
+ const profile = at(nsView ? nsView.value : undefined, entry.settingsPath || []);
414
+ const models = (profile && Array.isArray(profile.models)) ? profile.models : [];
415
+ const row = models.find((m) => m && typeof m === "object" && m.id === modelId);
416
+ if (row && row.reasoningEfforts !== undefined) return row.reasoningEfforts;
417
+ const overrides = profile && profile.modelOverrides && typeof profile.modelOverrides === "object"
418
+ ? profile.modelOverrides[modelId] : undefined;
419
+ return overrides && typeof overrides === "object" ? overrides.reasoningEfforts : undefined;
420
+ }
421
+
422
+ /**
423
+ * The `reasoningEfforts` value for a set of checked levels.
424
+ *
425
+ * Since dsh 0.2.1-alpha.4 the official Models page also edits this field and
426
+ * lets each level send its own API value (a gateway spelling `high` as
427
+ * `deep`). Toggling a level here keeps every value already stored for the
428
+ * levels that stay, including a non-null `off`; only a newly added level
429
+ * defaults to its own name.
430
+ * @param levels - checked levels, in any order.
431
+ * @param existing - the stored value, or undefined.
432
+ * @returns `false` for no reasoning, else the level → wire-value dict.
433
+ */
434
+ function reasoningEffortsPayload(levels, existing) {
435
+ const ordered = REASONING_LEVELS.filter((level) => levels.indexOf(level) >= 0);
436
+ if (ordered.length === 0) return false;
437
+ const stored = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
438
+ const dict = { off: stored.off !== undefined ? stored.off : null };
439
+ for (const level of ordered) {
440
+ const wire = stored[level];
441
+ dict[level] = typeof wire === "string" && wire !== "" ? wire : level;
442
+ }
443
+ return dict;
444
+ }
445
+
368
446
  function computeCompatWriteOps(entry, nsView, modelId, compatKey, value) {
369
447
  const ns = entry.settingsNs;
370
448
  const settingsPath = entry.settingsPath || [];
@@ -895,6 +973,7 @@ window.__ModuleLoader__.load({
895
973
  const nsView = entry ? nsByNs.get(entry.settingsNs) : undefined;
896
974
  const providerProfile = profileOf(nsView, entry ? entry.settingsPath : []);
897
975
  const reasoningEditable = !!(entry && entry.settingsNs === "llm-pi-ai");
976
+ const adaptiveKey = entry ? adaptiveThinkingCompatKey(entry.settingsNs, providerProfile) : null;
898
977
  const routeDefaultInput = providerProfile && Array.isArray(providerProfile.defaultInput)
899
978
  ? providerProfile.defaultInput
900
979
  : null;
@@ -904,11 +983,15 @@ window.__ModuleLoader__.load({
904
983
  const devRole = entry && entry.settingsNs === "llm-pi-ai"
905
984
  ? readCompatField(nsView, entry.settingsPath, m.id, "supportsDeveloperRole")
906
985
  : undefined;
986
+ const adaptiveThinking = adaptiveKey
987
+ ? readCompatField(nsView, entry.settingsPath, m.id, adaptiveKey)
988
+ : undefined;
907
989
  const modalityField = entry ? modalityFieldFor(entry.settingsNs) : undefined;
908
990
  const rawInput = modalityField
909
991
  ? readModelField(nsView, entry.settingsPath, m.id, modalityField)
910
992
  : undefined;
911
993
  const image = computeImageModelState(view, providerProfile, m.id);
994
+ const efforts = (m.reasoning && Array.isArray(m.reasoning.efforts)) ? m.reasoning.efforts.map((e) => ({ id: e.id, name: e.name })) : [];
912
995
  return {
913
996
  id: m.id,
914
997
  name: m.name,
@@ -916,6 +999,10 @@ window.__ModuleLoader__.load({
916
999
  contextWindow: ctx != null ? ctx : null,
917
1000
  maxTokens: mx != null ? mx : null,
918
1001
  supportsDeveloperRole: devRole,
1002
+ adaptiveThinking: adaptiveThinking === true,
1003
+ // The flag only changes a request that carries thinking at all,
1004
+ // so it is offered where a level can be selected.
1005
+ adaptiveThinkingEditable: adaptiveKey !== null && efforts.length > 0,
919
1006
  modalityField: modalityField || null,
920
1007
  modality: modalityField ? modalityChoice(rawInput) : null,
921
1008
  imageAvailable: image.available,
@@ -925,7 +1012,7 @@ window.__ModuleLoader__.load({
925
1012
  imageApiKeyEnv: image.apiKeyEnv || null,
926
1013
  declared: !!(entry && entry.declared),
927
1014
  routeDefaultInput: routeDefaultInput,
928
- efforts: (m.reasoning && Array.isArray(m.reasoning.efforts)) ? m.reasoning.efforts.map((e) => ({ id: e.id, name: e.name })) : [],
1015
+ efforts,
929
1016
  defaultEffort: (m.reasoning && m.reasoning.defaultEffort != null) ? m.reasoning.defaultEffort : null,
930
1017
  };
931
1018
  });
@@ -1010,25 +1097,19 @@ window.__ModuleLoader__.load({
1010
1097
  if (kind === "reasoningLevels") {
1011
1098
  field = "reasoningEfforts";
1012
1099
  const raw = value && Array.isArray(value.levels) ? value.levels : [];
1013
- const levels = REASONING_LEVELS.filter((level) => raw.indexOf(level) >= 0);
1014
- if (levels.length === 0) {
1015
- payload = false;
1016
- } else {
1017
- const dict = { off: null };
1018
- for (const level of levels) dict[level] = level;
1019
- payload = dict;
1020
- }
1100
+ payload = reasoningEffortsPayload(raw, storedReasoningEfforts(entry, nsView, model));
1021
1101
  } else if (kind === "contextWindow" || kind === "maxTokens") {
1022
1102
  field = kind;
1023
1103
  if (value != null && (typeof value !== "number" || !Number.isInteger(value) || value <= 0)) {
1024
1104
  throw new Error("取值必须是正整数");
1025
1105
  }
1026
1106
  payload = value;
1027
- } else if (kind === "supportsDeveloperRole") {
1107
+ } else if (kind === "supportsDeveloperRole" || kind === "adaptiveThinking") {
1028
1108
  if (value !== null && typeof value !== "boolean") {
1029
1109
  throw new Error("取值必须是布尔值");
1030
1110
  }
1031
- const write = computeCompatWriteOps(entry, nsView, model, "supportsDeveloperRole", value);
1111
+ const compatKey = kind === "supportsDeveloperRole" ? "supportsDeveloperRole" : ADAPTIVE_THINKING_COMPAT_KEY;
1112
+ const write = computeCompatWriteOps(entry, nsView, model, compatKey, value);
1032
1113
  if (write) {
1033
1114
  const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
1034
1115
  if (!res.result.ok) throw new Error(res.result.error.message);
@@ -1176,6 +1257,9 @@ window.__ModuleLoader__.load({
1176
1257
  : null,
1177
1258
  model.imageLinked
1178
1259
  ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "生图模型")
1260
+ : null,
1261
+ model.adaptiveThinking
1262
+ ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "自适应思考")
1179
1263
  : null
1180
1264
  )
1181
1265
  ),
@@ -1215,6 +1299,7 @@ window.__ModuleLoader__.load({
1215
1299
  React.createElement("input", {
1216
1300
  type: "radio",
1217
1301
  name: "devrole-" + model.id,
1302
+ disabled: !editable,
1218
1303
  checked: role === "auto"
1219
1304
  ? model.supportsDeveloperRole === undefined
1220
1305
  : (role === "developer" ? model.supportsDeveloperRole === true : model.supportsDeveloperRole === false),
@@ -1236,6 +1321,29 @@ window.__ModuleLoader__.load({
1236
1321
  "自动:跟随网关检测;如遇 role 'developer' is not allowed,请选 system"
1237
1322
  )
1238
1323
  ) : null,
1324
+ model.adaptiveThinkingEditable ? React.createElement("div", { className: "mcfg-reasoning" },
1325
+ React.createElement("span", { className: "mcfg-label" }, "思考格式(Anthropic 兼容开关)"),
1326
+ React.createElement("div", { className: "mcfg-levels" },
1327
+ ["auto", "adaptive"].map((choice) =>
1328
+ React.createElement("label", { key: choice, className: "mcfg-level" },
1329
+ React.createElement("input", {
1330
+ type: "radio",
1331
+ name: "adaptive-" + model.id,
1332
+ disabled: !editable,
1333
+ checked: choice === "auto" ? !model.adaptiveThinking : model.adaptiveThinking,
1334
+ onChange: () => applyChange(provider.provider, model.id, "adaptiveThinking", choice === "adaptive" ? true : null),
1335
+ }),
1336
+ React.createElement("span", null, choice === "auto" ? "自动(预算式 thinking)" : "自适应 thinking")
1337
+ )
1338
+ )
1339
+ ),
1340
+ React.createElement("div", { className: "mcfg-note" },
1341
+ "上面选中档位后,请求体由这里决定:自适应写 compat." + ADAPTIVE_THINKING_COMPAT_KEY
1342
+ + ",发出 thinking.type=adaptive 与 output_config.effort;Claude Opus 4.6 及更新、Sonnet 5、"
1343
+ + "Fable 5 等只接受这种,报 400 requires adaptive thinking 就切到它。"
1344
+ + "Claude 4.5 及更早只认预算式,保持「自动」。"
1345
+ )
1346
+ ) : null,
1239
1347
  model.modalityField ? React.createElement("div", { className: "mcfg-reasoning" },
1240
1348
  React.createElement("span", { className: "mcfg-label" }, "输入模态(写入 " + model.modalityField + ")"),
1241
1349
  React.createElement("div", { className: "mcfg-levels" },
package/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * patch in `cordis.patch.yml`. It is still exactly one provider:
11
11
  * `ctx.sessionTitle.register()` throws on a second registration, so a
12
12
  * competing generator cannot coexist. Everything except the route is
13
- * delegated to the public shared policy `generateSessionTitleWithLlm` from
13
+ * delegated to the public shared policy (`executeSessionTitleLlm`, or
14
+ * `generateSessionTitleWithLlm` before dsh 0.2.1) from
14
15
  * `@deepseek-ai/dsh-session-title-llm`, resolved from the RUNNING
15
16
  * deployment (see hostRequire) — never from this package's directory, which
16
17
  * for a linked workspace package would risk a second copy of the host's
@@ -140,7 +141,79 @@ function hostRequire() {
140
141
  return undefined
141
142
  }
142
143
 
144
+ /** Title-service package that owns `normalizeSessionTitle`; resolved next to the helper. */
145
+ const TITLE_SERVICE_PACKAGE = '@deepseek-ai/dsh-session-title'
146
+
147
+ /**
148
+ * Language-aware system instruction, mirroring the shipped first-prompt
149
+ * provider (dsh ≥ 0.2.1 hands prompt ownership to each provider).
150
+ * @param budget - the live title budget.
151
+ * @returns the system prompt.
152
+ */
153
+ export function titleSystemPrompt(budget) {
154
+ return [
155
+ 'Create a concise title for an AI coding-assistant session from the supplied human messages.',
156
+ 'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
157
+ 'Use the language of the messages.',
158
+ `Aim for about ${budget.targetWords} words in non-CJK languages or ${budget.targetCjkCharacters} CJK characters.`,
159
+ 'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
160
+ ].join('\n')
161
+ }
162
+
163
+ /** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
164
+ const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u
165
+
166
+ /**
167
+ * Take the title from model output: the first non-empty line, without
168
+ * asterisk emphasis wrapping that whole line (same rule as the shipped provider).
169
+ * @param text - the joined text blocks.
170
+ * @returns the raw title line.
171
+ */
172
+ export function titleFromOutput(text) {
173
+ const line = text.split(/\r?\n/u).map(item => item.trim()).find(item => item.length > 0) ?? ''
174
+ const groups = EMPHASIS_WRAPPER.exec(line)?.groups
175
+ const marker = groups?.marker
176
+ const inner = groups?.inner
177
+ return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner
178
+ }
179
+
180
+ /**
181
+ * Adapt the prepared-request API (`executeSessionTitleLlm`, dsh ≥ 0.2.1) to the
182
+ * one-call shape this plugin drives.
183
+ * @param execute - the host's `executeSessionTitleLlm`.
184
+ * @param normalize - the host's `normalizeSessionTitle`.
185
+ * @returns `(ctx, budget, request, selected, providerId) => { title, messageSeqs, model }`.
186
+ */
187
+ export function preparedTitleGenerator(execute, normalize) {
188
+ return async (ctx, budget, request, selected, providerId) => {
189
+ const config = { maxInputBytes: budget.maxInputBytes, maxOutputTokens: budget.maxOutputTokens, timeoutMs: budget.timeoutMs }
190
+ const messageSeqs = selected.map(message => message.seq)
191
+ const response = await execute(ctx, config, request, providerId, {
192
+ system: titleSystemPrompt(budget),
193
+ input: `Generate the session title from this JSON array of human messages:\n${JSON.stringify(selected)}`,
194
+ messageSeqs,
195
+ // A title needs no deliberation: take the route's least effort.
196
+ selectReasoningEffort: model => model.reasoning?.efforts?.[0]?.id,
197
+ })
198
+ if (response.finish.kind === 'tool-calls' || response.blocks.some(block => block.type === 'tool-call')) {
199
+ throw new Error('session-title-llm: title output must contain text only')
200
+ }
201
+ if (response.finish.kind === 'max-tokens') {
202
+ throw new Error('session-title-llm: title output reached maxOutputTokens')
203
+ }
204
+ const text = response.blocks.filter(block => block.type === 'text').map(block => block.text).join(' ')
205
+ const title = normalize(titleFromOutput(text), Number.MAX_SAFE_INTEGER)
206
+ if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
207
+ return { title, messageSeqs, model: response.model }
208
+ }
209
+ }
210
+
143
211
  let helperPromise
212
+ /**
213
+ * Load a title generator from the running deployment: the prepared-request
214
+ * API when the host has it (≥ 0.2.1), else the one-call
215
+ * `generateSessionTitleWithLlm` (0.1.7 – 0.2.0).
216
+ */
144
217
  function loadTitleHelper() {
145
218
  helperPromise ??= (async () => {
146
219
  const require = hostRequire()
@@ -149,12 +222,17 @@ function loadTitleHelper() {
149
222
  `model-tuning: 无法从宿主解析 ${HELPER_PACKAGE};本插件必须与提供该包的部署一起运行。`,
150
223
  )
151
224
  }
152
- const loaded = await import(pathToFileURL(require.resolve(HELPER_PACKAGE)).href)
153
- const generate = loaded.generateSessionTitleWithLlm
154
- if (typeof generate !== 'function') {
155
- throw new Error(`model-tuning: ${HELPER_PACKAGE} 未导出 generateSessionTitleWithLlm`)
225
+ const helperPath = require.resolve(HELPER_PACKAGE)
226
+ const loaded = await import(pathToFileURL(helperPath).href)
227
+ if (typeof loaded.executeSessionTitleLlm === 'function') {
228
+ const service = await import(pathToFileURL(createRequire(helperPath).resolve(TITLE_SERVICE_PACKAGE)).href)
229
+ if (typeof service.normalizeSessionTitle !== 'function') {
230
+ throw new Error(`model-tuning: ${TITLE_SERVICE_PACKAGE} 未导出 normalizeSessionTitle`)
231
+ }
232
+ return preparedTitleGenerator(loaded.executeSessionTitleLlm, service.normalizeSessionTitle)
156
233
  }
157
- return generate
234
+ if (typeof loaded.generateSessionTitleWithLlm === 'function') return loaded.generateSessionTitleWithLlm
235
+ throw new Error(`model-tuning: ${HELPER_PACKAGE} 既没有 executeSessionTitleLlm 也没有 generateSessionTitleWithLlm`)
158
236
  })()
159
237
  return helperPromise
160
238
  }
@@ -189,7 +267,7 @@ export function titleBudgetOf(config) {
189
267
  * @param config - the live Config (route and budget are re-read per generation).
190
268
  */
191
269
  export async function registerTitleProvider(ctx, config) {
192
- const generateSessionTitleWithLlm = await loadTitleHelper()
270
+ const generateTitle = await loadTitleHelper()
193
271
  validateTitleModel({ titleMode: config.titleMode.get(), titleProvider: config.titleProvider.get(), titleModel: config.titleModel.get() })
194
272
  titleBudgetOf(config)
195
273
  ctx.sessionTitle.register({
@@ -200,7 +278,7 @@ export async function registerTitleProvider(ctx, config) {
200
278
  titleMode: config.titleMode.get(), titleProvider: config.titleProvider.get(), titleModel: config.titleModel.get(),
201
279
  }, request)
202
280
  const selected = request.messages.slice(0, 1)
203
- return await generateSessionTitleWithLlm(ctx, titleBudgetOf(config), { ...request, route }, selected, TITLE_PROVIDER_ID)
281
+ return await generateTitle(ctx, titleBudgetOf(config), { ...request, route }, selected, TITLE_PROVIDER_ID)
204
282
  },
205
283
  })
206
284
  }
@@ -361,6 +439,13 @@ export function registerImageTools(ctx, config) {
361
439
  * @param config - the unified Config described above.
362
440
  */
363
441
  export async function apply(ctx, config) {
364
- await registerTitleProvider(ctx, config)
442
+ // The title route leans on a host helper whose API has changed between
443
+ // releases. If it breaks again, lose only title routing — never the image
444
+ // tools and settings page that ride on the same row.
445
+ try {
446
+ await registerTitleProvider(ctx, config)
447
+ } catch (error) {
448
+ ctx.logger?.warn?.(`model-tuning: 标题路由未启用(${error instanceof Error ? error.message : String(error)});绘图与设置页照常工作`)
449
+ }
365
450
  registerImageTools(ctx, config)
366
451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-model-tuning",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "license": "MIT",
5
5
  "keywords": ["dsh-plugin", "deepseek-harness", "dsh", "model", "settings", "reasoning", "context", "session-title", "image-generation"],
6
6
  "publishConfig": { "registry": "https://registry.npmjs.org" },