dsh-auto-thinking-levels 0.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 +21 -0
- package/README.md +131 -0
- package/cordis.patch.yml +30 -0
- package/index.js +215 -0
- package/lib/plan.js +172 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lolkda
|
|
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
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# dsh-auto-thinking-levels
|
|
2
|
+
|
|
3
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) 的 Host 插件:
|
|
4
|
+
**自动给每个提供商、每个模型补上完整的思考等级表(`reasoningEfforts`)**,不用再逐模型手写。
|
|
5
|
+
|
|
6
|
+
> **English TL;DR** — A dsh Host plugin that keeps every `llm-pi-ai` provider route
|
|
7
|
+
> offering the full thinking-level set. It writes the missing `reasoningEfforts`
|
|
8
|
+
> tables into that namespace's settings layer, so both the model selector *and*
|
|
9
|
+
> the request path agree on which levels exist. Add-only and idempotent: it never
|
|
10
|
+
> rewrites a level you already spelled, never removes an entry, and never touches
|
|
11
|
+
> a model that declares `reasoningEfforts: false`.
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
reasoningEfforts:
|
|
15
|
+
{ off: null, minimal: minimal, low: low, medium: medium, high: high, xhigh: xhigh, max: max }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 为什么写配置层,而不是去 patch adapter
|
|
19
|
+
|
|
20
|
+
`llm-pi-ai` 在**物化模型时**读一次 `reasoningEfforts`,把它翻译成 pi-ai 描述符上的
|
|
21
|
+
`thinkingLevelMap`(`resolveModelReasoning`)。之后所有判断都只从这张 map 出发:
|
|
22
|
+
|
|
23
|
+
- `getSupportedThinkingLevels()` 决定选择器**显示**哪些档位;
|
|
24
|
+
- **同一个函数**守着请求路径,map 里没有的档位在发出任何 I/O 之前就以
|
|
25
|
+
`UNSUPPORTED_REASONING_EFFORT` 被拒。
|
|
26
|
+
|
|
27
|
+
所以只包一层 adapter 的 `resolveModel` 只能改到第一个答案、改不到第二个 —— 用户会选到
|
|
28
|
+
一个真正发请求时被拒的档位。把表写进配置层,是让**配置本身正确**,两个答案都从它推导,
|
|
29
|
+
且任何一次重载后依然成立,不需要重新打补丁。
|
|
30
|
+
|
|
31
|
+
写入走 `settings.update`:候选值会先过该命名空间自己的 schema 与 `assertServiceable`
|
|
32
|
+
校验,**注入不合法时什么都不落盘**。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
dsh plugin --profile <name> add dsh-auto-thinking-levels
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
插件自带 `dsh.bundle.patch`,安装后自动挂一行,不需要手改 `cordis.patch.yml`。
|
|
41
|
+
|
|
42
|
+
想改配置的话,在 profile 的 `cordis.patch.yml` 里用 id 定向 patch 覆盖那一行即可;
|
|
43
|
+
改完用 `set_bundle` 关掉再打开,这一行就会用新配置重新挂载。
|
|
44
|
+
|
|
45
|
+
## 配置
|
|
46
|
+
|
|
47
|
+
全部字段可选,默认值见下表。
|
|
48
|
+
|
|
49
|
+
| 字段 | 默认 | 含义 |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| `namespace` | `llm-pi-ai` | 要配置的 settings 命名空间(`dsh-llm-pi-ai` 的 `NS`) |
|
|
52
|
+
| `fill` | `complete` | `complete` 把**档位不足**的模型补齐(已写过的档位保留你的原值);`missing` 只补完全没有表的模型,尊重刻意的子集 |
|
|
53
|
+
| `levels` | 七档全表 | 要写入的表;值是发给网关的线材拼写,`off: null` 表示「支持,但不发送该参数」 |
|
|
54
|
+
| `providers` | `[]` | 路由白名单,空 = 全部已注册路由 |
|
|
55
|
+
| `enabled` | `true` | `false` 时挂载但不动配置 |
|
|
56
|
+
|
|
57
|
+
## 行为
|
|
58
|
+
|
|
59
|
+
只做**新增**,绝不覆盖:
|
|
60
|
+
|
|
61
|
+
- 已有部分档位的模型(`fill: complete`):补齐缺失的档位,**已写过的档位保留你的原值**;
|
|
62
|
+
- 声明 `reasoningEfforts: false` 的模型:`false` 是「此模型不推理」的明确答复,永不填充;
|
|
63
|
+
- 其余任何字段、任何条目都不会被删改。
|
|
64
|
+
|
|
65
|
+
因此是幂等的:第二遍扫描找不到要补的,就不写、不发事件,自然收敛。
|
|
66
|
+
|
|
67
|
+
插件会监听该命名空间的变更与 adapter 拓扑变化,所以**你手改 `settings.yaml` 之后它会自己补回来**。
|
|
68
|
+
|
|
69
|
+
## 两种路由
|
|
70
|
+
|
|
71
|
+
- **声明了 `models` 的路由**:逐条 entry 补表。
|
|
72
|
+
- **没有 `models` 的路由**(catalog 路由):只能通过 `modelOverrides` 表达,因此按
|
|
73
|
+
`ctx.llm.listModels(route)` **实际服务**的模型逐个补 —— adapter 会拒绝命名
|
|
74
|
+
catalog 里不存在的模型,所以这里用真实服务列表而不是去猜 catalog。
|
|
75
|
+
两种方式互斥:`models` 旁边放 `modelOverrides` 会被 adapter 拒绝。
|
|
76
|
+
|
|
77
|
+
## 已知边界
|
|
78
|
+
|
|
79
|
+
本插件只配置 `llm-pi-ai` 命名空间。**其它 adapter 不在作用域内,且有些无法被配置**:
|
|
80
|
+
|
|
81
|
+
`deepseek-official`(`llm-deepseek` adapter)拿不到七档 —— 它的档位写死在 adapter 里
|
|
82
|
+
(`off/low/high/max`),线材校验函数 `reasoningEffort()` 对其余值直接抛
|
|
83
|
+
`UNSUPPORTED_REASONING_EFFORT`;它的 settings schema 也只有 provider 级 `reasoningEffort`,
|
|
84
|
+
没有 per-model 表。这是官方 API 自己的线材词汇(`medium` 会收敛到 `high`),不是配置能改的。
|
|
85
|
+
强行为它注入 `minimal/medium/xhigh` 只会把请求打成必然失败。
|
|
86
|
+
|
|
87
|
+
## 验证
|
|
88
|
+
|
|
89
|
+
`verification/probe-result.json` 是一次性探针在运行中的 harness 上抓的实测结果:对每个
|
|
90
|
+
已注册路由/模型调用 `llm.resolveModelInfo`(选择器看到的档位)与
|
|
91
|
+
`llm.resolveCallConfig`(请求路径是否接受该档位)。
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
## provider: cpa
|
|
95
|
+
deepseek-v4-flash-0731 efforts: off/minimal/low/medium/high/xhigh/max rejected: none
|
|
96
|
+
glm-5.3-flash efforts: off/minimal/low/medium/high/xhigh/max rejected: none
|
|
97
|
+
deepseek-flash efforts: off/minimal/low/medium/high/xhigh/max rejected: none
|
|
98
|
+
gpt-6-astra efforts: off/minimal/low/medium/high/xhigh/max rejected: none
|
|
99
|
+
claude-fable-5-1 efforts: off/minimal/low/medium/high/xhigh/max rejected: none
|
|
100
|
+
glm-5.3-flashx efforts: off/minimal/low/medium/high/xhigh/max rejected: none
|
|
101
|
+
|
|
102
|
+
## provider: deepseek-official
|
|
103
|
+
deepseek-flash efforts: off/low/high/max rejected: minimal,medium,xhigh
|
|
104
|
+
deepseek-v4-pro efforts: off/low/high/max rejected: minimal,medium,xhigh
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
(`resolveModelInfo` 里的 `auto` 由另一个插件注入,不属于本插件。)
|
|
108
|
+
|
|
109
|
+
## 开发
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
npm test # node:test,20 个用例,零依赖
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`lib/plan.js` 是纯决策逻辑(可测、无 I/O),`index.js` 只负责读写 settings 与事件触发。
|
|
116
|
+
|
|
117
|
+
发布走 tag:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
npm version patch
|
|
121
|
+
git push --follow-tags
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`.github/workflows/publish.yml` 会校验 tag 与 `package.json` 版本一致、跑测试、以
|
|
125
|
+
provenance 发布到 npm,并开一个 GitHub Release。认证用 npm 的
|
|
126
|
+
[trusted publishing](https://docs.npmjs.com/trusted-publishers)(OIDC),**不需要
|
|
127
|
+
`NPM_TOKEN` secret** —— 前提是在 npmjs.com 的包设置里登记过本仓库与 workflow 名。
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# dsh-auto-thinking-levels — mounts one Host row.
|
|
2
|
+
#
|
|
3
|
+
# `fill: complete` is the shipped policy: every model of every route in the
|
|
4
|
+
# namespace below is brought to the full thinking-level set, and a model that
|
|
5
|
+
# already spells some levels keeps its own spellings. Use `fill: missing` to
|
|
6
|
+
# leave a deliberately partial table alone instead.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-auto-thinking-levels
|
|
9
|
+
name: dsh-auto-thinking-levels
|
|
10
|
+
config:
|
|
11
|
+
fill: complete
|
|
12
|
+
# Settings namespace to configure. Default: llm-pi-ai
|
|
13
|
+
# namespace: llm-pi-ai
|
|
14
|
+
#
|
|
15
|
+
# The table written, as level -> wire spelling. `off: null` means
|
|
16
|
+
# "supported, send nothing". Default:
|
|
17
|
+
# levels:
|
|
18
|
+
# off: null
|
|
19
|
+
# minimal: minimal
|
|
20
|
+
# low: low
|
|
21
|
+
# medium: medium
|
|
22
|
+
# high: high
|
|
23
|
+
# xhigh: xhigh
|
|
24
|
+
# max: max
|
|
25
|
+
#
|
|
26
|
+
# Provider routes to configure; empty means every registered route.
|
|
27
|
+
# providers: []
|
|
28
|
+
#
|
|
29
|
+
# false mounts the row without touching settings.
|
|
30
|
+
# enabled: true
|
package/index.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-auto-thinking-levels — Host plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* The decision logic lives in `./lib/plan.js` and is pure. This module owns the
|
|
5
|
+
* I/O: reading the settings namespace, writing through `settings.update`, and
|
|
6
|
+
* the triggers that keep the plan applied.
|
|
7
|
+
*
|
|
8
|
+
* Why the settings layer is the right seam: `llm-pi-ai` reads a model's
|
|
9
|
+
* `reasoningEfforts` table at materialization time and turns it into the pi-ai
|
|
10
|
+
* descriptor's `thinkingLevelMap` (`resolveModelReasoning`). Every later
|
|
11
|
+
* question is answered from that map, and only from it:
|
|
12
|
+
*
|
|
13
|
+
* - `getSupportedThinkingLevels()` decides which levels a selector offers;
|
|
14
|
+
* - the same function gates the request path, so a level the map does not
|
|
15
|
+
* carry is refused with `UNSUPPORTED_REASONING_EFFORT` before any I/O.
|
|
16
|
+
*
|
|
17
|
+
* So patching an adapter's `resolveModel` could only ever move the first
|
|
18
|
+
* answer, not the second, and the user would pick a level the provider call
|
|
19
|
+
* then rejects. Writing the table into the namespace's user layer instead makes
|
|
20
|
+
* the *configuration* correct, so both answers follow from it and survive every
|
|
21
|
+
* reload without a re-patch. Writes go through `settings.update`, which
|
|
22
|
+
* resolves and validates the candidate through the namespace's own schema and
|
|
23
|
+
* `assertServiceable` before persisting — an injection that would not serve is
|
|
24
|
+
* refused with nothing written.
|
|
25
|
+
*
|
|
26
|
+
* The plugin only ever ADDS the field. It never rewrites a level the user
|
|
27
|
+
* already spelled, never removes an entry, and never touches a model that
|
|
28
|
+
* declares `reasoningEfforts: false` (the explicit "this model does not
|
|
29
|
+
* reason" answer). That makes it idempotent: a second pass finds nothing to do
|
|
30
|
+
* and writes nothing.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-auto-thinking-levels
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { plainObject, planModels, planOverrides, readOptions, summarize } from './lib/plan.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Delays before re-reading a namespace that is not registered yet. The pi-ai
|
|
39
|
+
* adapter installs its settings section during its own `apply`, and row order
|
|
40
|
+
* decides whether that happened before this one — and `settings.register()`
|
|
41
|
+
* emits no event, so a section appearing after us is not observable. A short
|
|
42
|
+
* bounded ladder covers the startup race; the event listeners below cover
|
|
43
|
+
* everything after it.
|
|
44
|
+
*/
|
|
45
|
+
const RETRY_LADDER_MS = Object.freeze([200, 500, 1000, 2000, 4000, 8000]);
|
|
46
|
+
|
|
47
|
+
export const name = 'dsh-auto-thinking-levels';
|
|
48
|
+
|
|
49
|
+
/** The adapter must exist before there is a namespace worth configuring. */
|
|
50
|
+
export const inject = ['llm'];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mount the row. The plugin is inert until a settings provider is present,
|
|
54
|
+
* because without one there is no user layer to write and no resolved value to
|
|
55
|
+
* compare against.
|
|
56
|
+
* @param ctx - the row's Cordis context.
|
|
57
|
+
* @param config - the row's `config` block, when the patch supplies one.
|
|
58
|
+
* @throws {TypeError} when a supplied field is not one this plugin understands.
|
|
59
|
+
*/
|
|
60
|
+
export function apply(ctx, config) {
|
|
61
|
+
const options = readOptions(config);
|
|
62
|
+
if (!options.enabled) {
|
|
63
|
+
ctx.logger.info('[%s] disabled by configuration; no namespace will be written', name);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
68
|
+
const settings = settingsCtx.settings;
|
|
69
|
+
/** One pass at a time; a trigger arriving mid-pass sets the rerun flag instead. */
|
|
70
|
+
let running = false;
|
|
71
|
+
let rerun = false;
|
|
72
|
+
/** Pending retry handle, and how many ladder steps it has consumed. */
|
|
73
|
+
let timer;
|
|
74
|
+
let attempt = 0;
|
|
75
|
+
let disposed = false;
|
|
76
|
+
|
|
77
|
+
const cancel = () => {
|
|
78
|
+
if (timer === undefined) return;
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
timer = undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const schedule = (delayMs) => {
|
|
84
|
+
if (disposed) return;
|
|
85
|
+
cancel();
|
|
86
|
+
timer = setTimeout(() => {
|
|
87
|
+
timer = undefined;
|
|
88
|
+
void pump();
|
|
89
|
+
}, delayMs);
|
|
90
|
+
// `setTimeout` alone would keep a disposed profile alive for one tick.
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Run passes until a pass settles, then stop; never overlaps. */
|
|
95
|
+
async function pump() {
|
|
96
|
+
if (disposed) return;
|
|
97
|
+
if (running) {
|
|
98
|
+
rerun = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
running = true;
|
|
102
|
+
try {
|
|
103
|
+
do {
|
|
104
|
+
rerun = false;
|
|
105
|
+
const outcome = await sync();
|
|
106
|
+
if (outcome === 'deferred') {
|
|
107
|
+
const delay = RETRY_LADDER_MS[Math.min(attempt, RETRY_LADDER_MS.length - 1)];
|
|
108
|
+
const more = attempt < RETRY_LADDER_MS.length;
|
|
109
|
+
attempt += 1;
|
|
110
|
+
if (more) schedule(delay);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
attempt = 0;
|
|
114
|
+
} while (rerun);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// A failed pass must never escape into the event bus: the settings
|
|
117
|
+
// document is untouched, and the next trigger tries again.
|
|
118
|
+
ctx.logger.warn('[%s] sync failed; the settings document was left untouched', name);
|
|
119
|
+
ctx.logger.warn(error);
|
|
120
|
+
} finally {
|
|
121
|
+
running = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One reconcile pass.
|
|
127
|
+
* @returns `'done'` when the namespace was read (whether or not it needed a
|
|
128
|
+
* write), or `'deferred'` when it is not registered yet.
|
|
129
|
+
*/
|
|
130
|
+
async function sync() {
|
|
131
|
+
const descriptor = settings
|
|
132
|
+
.describe()
|
|
133
|
+
.find((entry) => String(entry.ns) === options.namespace);
|
|
134
|
+
if (descriptor === undefined) return 'deferred';
|
|
135
|
+
|
|
136
|
+
const user = plainObject(descriptor.user) ? descriptor.user : {};
|
|
137
|
+
const resolved = plainObject(descriptor.value) ? descriptor.value : {};
|
|
138
|
+
const userProviders = plainObject(user.providers) ? user.providers : {};
|
|
139
|
+
const resolvedProviders = plainObject(resolved.providers) ? resolved.providers : {};
|
|
140
|
+
|
|
141
|
+
const routes = Object.keys(resolvedProviders).filter(
|
|
142
|
+
(route) => options.providers.length === 0 || options.providers.includes(route),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const patch = {};
|
|
146
|
+
const added = [];
|
|
147
|
+
for (const route of routes) {
|
|
148
|
+
const raw = plainObject(userProviders[route]) ? userProviders[route] : {};
|
|
149
|
+
// A route that declares `models` gets the table on each entry. A route
|
|
150
|
+
// that does not is a catalog route, whose served models are only
|
|
151
|
+
// nameable through `modelOverrides` — and `modelOverrides` beside a
|
|
152
|
+
// `models` list is refused by the adapter, so the two never combine.
|
|
153
|
+
const declared = raw.models;
|
|
154
|
+
if (Array.isArray(declared) && declared.length > 0) {
|
|
155
|
+
const next = planModels(route, declared, options, added);
|
|
156
|
+
if (next !== undefined) patch[route] = { models: next };
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const overrides = await planRouteOverrides(route, raw.modelOverrides, added);
|
|
160
|
+
if (overrides !== undefined) patch[route] = { modelOverrides: overrides };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Object.keys(patch).length === 0) return 'done';
|
|
164
|
+
|
|
165
|
+
ctx.logger.info(
|
|
166
|
+
'[%s] %s: adding reasoningEfforts for %d model(s) across %d provider route(s) — %s',
|
|
167
|
+
name,
|
|
168
|
+
options.namespace,
|
|
169
|
+
added.length,
|
|
170
|
+
Object.keys(patch).length,
|
|
171
|
+
summarize(added),
|
|
172
|
+
);
|
|
173
|
+
await settings.update(options.namespace, { providers: patch }, descriptor.revision);
|
|
174
|
+
return 'done';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Ask the adapter which models a catalog route actually serves, then plan
|
|
179
|
+
* overrides for them.
|
|
180
|
+
* @param route - route key.
|
|
181
|
+
* @param existing - the route's raw `modelOverrides`, when it has any.
|
|
182
|
+
* @param added - collector of `route/model` labels actually changed.
|
|
183
|
+
* @returns the replacement dict, or `undefined` when nothing was missing.
|
|
184
|
+
*/
|
|
185
|
+
async function planRouteOverrides(route, existing, added) {
|
|
186
|
+
const llm = ctx.get('llm');
|
|
187
|
+
if (llm === undefined) return undefined;
|
|
188
|
+
let served;
|
|
189
|
+
try {
|
|
190
|
+
served = await llm.listModels(route);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// A route whose adapter is mid-replacement reports nothing to serve;
|
|
193
|
+
// the next trigger retries rather than failing the pass.
|
|
194
|
+
ctx.logger.warn('[%s] could not list models for route "%s"', name, route);
|
|
195
|
+
ctx.logger.warn(error);
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const ids = served.map((model) => model?.id).filter((id) => typeof id === 'string');
|
|
199
|
+
return planOverrides(route, existing, ids, options, added);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
settingsCtx.effect(() => () => {
|
|
203
|
+
disposed = true;
|
|
204
|
+
cancel();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
settingsCtx.on('settings/updated', (ns) => {
|
|
208
|
+
if (String(ns) === options.namespace) void pump();
|
|
209
|
+
});
|
|
210
|
+
// Routes appearing or disappearing re-shapes which models exist to cover.
|
|
211
|
+
settingsCtx.on('llm/adapters-updated', () => void pump());
|
|
212
|
+
|
|
213
|
+
void pump();
|
|
214
|
+
});
|
|
215
|
+
}
|
package/lib/plan.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning logic for dsh-auto-thinking-levels.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a total function over plain data: it decides *what* the
|
|
5
|
+
* settings document should say, and never reads or writes anything. The plugin
|
|
6
|
+
* entry (`../index.js`) owns the I/O. Keeping the split means the interesting
|
|
7
|
+
* rules — which models get filled, which are deliberately left alone, what the
|
|
8
|
+
* merged table looks like — are testable without a running harness.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-auto-thinking-levels/plan
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The pi-ai adapter's settings namespace (`dsh-llm-pi-ai`'s `NS` constant). */
|
|
14
|
+
export const DEFAULT_NAMESPACE = 'llm-pi-ai';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The seven levels pi-ai's `ModelThinkingLevel` declares, in escalation order,
|
|
18
|
+
* with the wire spelling each sends. `off: null` is the one level allowed to
|
|
19
|
+
* carry no value — it means "supported, send nothing", which is the correct
|
|
20
|
+
* dispatch wherever not thinking is the reasoning parameter's absence.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_LEVELS = Object.freeze({
|
|
23
|
+
off: null,
|
|
24
|
+
minimal: 'minimal',
|
|
25
|
+
low: 'low',
|
|
26
|
+
medium: 'medium',
|
|
27
|
+
high: 'high',
|
|
28
|
+
xhigh: 'xhigh',
|
|
29
|
+
max: 'max',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** How much of each model's table this plugin is allowed to own. */
|
|
33
|
+
export const FILL_MODES = Object.freeze(['missing', 'complete']);
|
|
34
|
+
|
|
35
|
+
/** Whether a value is a plain data object, the only shape a settings layer holds. */
|
|
36
|
+
export function plainObject(value) {
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
38
|
+
const proto = Object.getPrototypeOf(value);
|
|
39
|
+
return proto === Object.prototype || proto === null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate the row's `config` block. Every field is optional; the defaults
|
|
44
|
+
* already mean "every provider, every model, the full seven levels".
|
|
45
|
+
* @param config - the raw row config, when the patch supplies one.
|
|
46
|
+
* @returns the resolved options.
|
|
47
|
+
* @throws {TypeError} naming the offending field.
|
|
48
|
+
*/
|
|
49
|
+
export function readOptions(config) {
|
|
50
|
+
if (config !== undefined && !plainObject(config)) {
|
|
51
|
+
throw new TypeError('dsh-auto-thinking-levels: config must be a mapping of keys');
|
|
52
|
+
}
|
|
53
|
+
const raw = config ?? {};
|
|
54
|
+
|
|
55
|
+
const namespace = raw.namespace ?? DEFAULT_NAMESPACE;
|
|
56
|
+
if (typeof namespace !== 'string' || namespace.length === 0) {
|
|
57
|
+
throw new TypeError('dsh-auto-thinking-levels: config.namespace must be a non-empty string');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fill = raw.fill ?? 'complete';
|
|
61
|
+
if (!FILL_MODES.includes(fill)) {
|
|
62
|
+
throw new TypeError(`dsh-auto-thinking-levels: config.fill must be one of ${FILL_MODES.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const levels = raw.levels ?? DEFAULT_LEVELS;
|
|
66
|
+
if (!plainObject(levels) || Object.keys(levels).length === 0) {
|
|
67
|
+
throw new TypeError('dsh-auto-thinking-levels: config.levels must be a non-empty mapping of level -> wire value');
|
|
68
|
+
}
|
|
69
|
+
for (const [level, wire] of Object.entries(levels)) {
|
|
70
|
+
if (wire === null) continue;
|
|
71
|
+
if (typeof wire !== 'string' || wire.length === 0) {
|
|
72
|
+
throw new TypeError(`dsh-auto-thinking-levels: config.levels.${level} must be a non-empty string, or null for "send nothing"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const providers = raw.providers ?? [];
|
|
77
|
+
if (!Array.isArray(providers) || providers.some((entry) => typeof entry !== 'string')) {
|
|
78
|
+
throw new TypeError('dsh-auto-thinking-levels: config.providers must be an array of provider route names');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const enabled = raw.enabled ?? true;
|
|
82
|
+
if (typeof enabled !== 'boolean') {
|
|
83
|
+
throw new TypeError('dsh-auto-thinking-levels: config.enabled must be a boolean');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const known = new Set(['namespace', 'fill', 'levels', 'providers', 'enabled']);
|
|
87
|
+
const unknown = Object.keys(raw).filter((key) => !known.has(key));
|
|
88
|
+
if (unknown.length > 0) {
|
|
89
|
+
throw new TypeError(`dsh-auto-thinking-levels: unknown config key(s): ${unknown.join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { namespace, fill, levels, providers, enabled };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decide the table one model should carry.
|
|
97
|
+
* @param current - the model's declared `reasoningEfforts`, when it has one.
|
|
98
|
+
* @param options - resolved plugin options.
|
|
99
|
+
* @returns the table to write, or `undefined` to leave the model alone.
|
|
100
|
+
*/
|
|
101
|
+
export function mergeEfforts(current, options) {
|
|
102
|
+
// `false` is the model's own "does not reason" answer — an explicit
|
|
103
|
+
// declaration, not a gap, and the one value this plugin must not fill in.
|
|
104
|
+
if (current === false) return undefined;
|
|
105
|
+
if (current === undefined) return { ...options.levels };
|
|
106
|
+
if (!plainObject(current)) return undefined;
|
|
107
|
+
if (options.fill !== 'complete') return undefined;
|
|
108
|
+
const incomplete = Object.keys(options.levels).some((level) => !(level in current));
|
|
109
|
+
if (!incomplete) return undefined;
|
|
110
|
+
// The user's own spellings win over the plugin's defaults, key for key.
|
|
111
|
+
return { ...options.levels, ...current };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Top up a route's declared `models` list.
|
|
116
|
+
* @param route - route key, for the change report.
|
|
117
|
+
* @param models - the route's raw `models` array.
|
|
118
|
+
* @param options - resolved plugin options.
|
|
119
|
+
* @param added - collector of `route/model` labels actually changed.
|
|
120
|
+
* @returns the replacement array, or `undefined` when every entry is already complete.
|
|
121
|
+
*/
|
|
122
|
+
export function planModels(route, models, options, added) {
|
|
123
|
+
let changed = false;
|
|
124
|
+
const next = models.map((entry) => {
|
|
125
|
+
if (!plainObject(entry)) return entry;
|
|
126
|
+
const merged = mergeEfforts(entry.reasoningEfforts, options);
|
|
127
|
+
if (merged === undefined) return entry;
|
|
128
|
+
changed = true;
|
|
129
|
+
added.push(`${route}/${String(entry.id)}`);
|
|
130
|
+
return { ...entry, reasoningEfforts: merged };
|
|
131
|
+
});
|
|
132
|
+
return changed ? next : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Top up a catalog route through `modelOverrides`, one entry per model the
|
|
137
|
+
* adapter actually serves. The adapter refuses an override naming a model the
|
|
138
|
+
* installed catalog does not describe, so the served list — not a guess at the
|
|
139
|
+
* catalog — is what gets named here.
|
|
140
|
+
* @param route - route key, for the change report.
|
|
141
|
+
* @param existing - the route's raw `modelOverrides`, when it has any.
|
|
142
|
+
* @param servedIds - ids the adapter reports serving for this route.
|
|
143
|
+
* @param options - resolved plugin options.
|
|
144
|
+
* @param added - collector of `route/model` labels actually changed.
|
|
145
|
+
* @returns the replacement dict, or `undefined` when nothing was missing.
|
|
146
|
+
*/
|
|
147
|
+
export function planOverrides(route, existing, servedIds, options, added) {
|
|
148
|
+
const current = plainObject(existing) ? existing : {};
|
|
149
|
+
const next = { ...current };
|
|
150
|
+
let changed = false;
|
|
151
|
+
for (const id of servedIds) {
|
|
152
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
153
|
+
const entry = plainObject(current[id]) ? current[id] : {};
|
|
154
|
+
const merged = mergeEfforts(entry.reasoningEfforts, options);
|
|
155
|
+
if (merged === undefined) continue;
|
|
156
|
+
next[id] = { ...entry, reasoningEfforts: merged };
|
|
157
|
+
changed = true;
|
|
158
|
+
added.push(`${route}/${id}`);
|
|
159
|
+
}
|
|
160
|
+
return changed ? next : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Bound a change report: a catalog route can carry dozens of models, and one
|
|
165
|
+
* log line should stay one log line.
|
|
166
|
+
* @param labels - `route/model` labels that were changed.
|
|
167
|
+
* @returns the labels, truncated with a remainder count.
|
|
168
|
+
*/
|
|
169
|
+
export function summarize(labels) {
|
|
170
|
+
const shown = labels.slice(0, 8).join(', ');
|
|
171
|
+
return labels.length > 8 ? `${shown}, +${labels.length - 8} more` : shown;
|
|
172
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-auto-thinking-levels",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH Host plugin: keep every llm-pi-ai provider route offering the full thinking-level set by writing the missing reasoningEfforts tables into that namespace's settings.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./plan": "./lib/plan.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"lib/",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"check:pack": "node scripts/check-pack.mjs"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"deepseek-harness",
|
|
29
|
+
"cordis",
|
|
30
|
+
"cordis-plugin",
|
|
31
|
+
"plugin",
|
|
32
|
+
"llm",
|
|
33
|
+
"reasoning-effort",
|
|
34
|
+
"thinking-levels",
|
|
35
|
+
"reasoningEfforts"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"author": "lolkda",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/lolkda/dsh-auto-thinking-levels.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/lolkda/dsh-auto-thinking-levels#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/lolkda/dsh-auto-thinking-levels/issues"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|