dsh-plugin-model-tuning 2.0.2 → 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.
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
@@ -177,6 +177,21 @@ thinking.type=adaptive and output_config.effort","type":"invalid_request_error"}
177
177
  > `dist/api/anthropic-messages.js` 是唯一读它的地方。`tests/adaptive-thinking.test.mjs`
178
178
  > 在有 `DSH_TEST_DEPLOY_ROOT` 时直接问该部署的 pi-ai 要请求体,上游改名会在这里失败而不是在会话里变成 400。
179
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
+
180
195
  ## 与 DSH 版本的耦合点(0.1.7-alpha.2)
181
196
 
182
197
  - **provider 目录必须取 `llm.listConfigurableProviders()`**(返回 `LlmConfigurableProvider`
package/client.js CHANGED
@@ -405,6 +405,44 @@ window.__ModuleLoader__.load({
405
405
  return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [{ id: modelId, [field]: value }] }] };
406
406
  }
407
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
+
408
446
  function computeCompatWriteOps(entry, nsView, modelId, compatKey, value) {
409
447
  const ns = entry.settingsNs;
410
448
  const settingsPath = entry.settingsPath || [];
@@ -1059,14 +1097,7 @@ window.__ModuleLoader__.load({
1059
1097
  if (kind === "reasoningLevels") {
1060
1098
  field = "reasoningEfforts";
1061
1099
  const raw = value && Array.isArray(value.levels) ? value.levels : [];
1062
- const levels = REASONING_LEVELS.filter((level) => raw.indexOf(level) >= 0);
1063
- if (levels.length === 0) {
1064
- payload = false;
1065
- } else {
1066
- const dict = { off: null };
1067
- for (const level of levels) dict[level] = level;
1068
- payload = dict;
1069
- }
1100
+ payload = reasoningEffortsPayload(raw, storedReasoningEfforts(entry, nsView, model));
1070
1101
  } else if (kind === "contextWindow" || kind === "maxTokens") {
1071
1102
  field = kind;
1072
1103
  if (value != null && (typeof value !== "number" || !Number.isInteger(value) || value <= 0)) {
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.2",
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" },