dsh-model-params 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 +38 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +267 -0
- package/lib/index.js +136 -0
- package/lib/modelsdev.js +106 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-subagent-conductor 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
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# dsh-model-params
|
|
2
|
+
|
|
3
|
+
## English
|
|
4
|
+
|
|
5
|
+
**Current release: 0.1.0** — models.dev parameter assistant inside the official DeepSeek Harness Models settings page.
|
|
6
|
+
|
|
7
|
+
For every configured `llm-pi-ai` provider card (custom OpenAI-compatible gateways such as the ones you add under Settings → Models), a `models.dev 参数` control fetches the official models.dev records for the provider's configured model ids and proposes the metadata pi-ai needs: context window, max output tokens, and reasoning-effort levels. One click writes the merged `models` array back to that provider profile through the official revision-aware Settings API.
|
|
8
|
+
|
|
9
|
+
Safety boundaries:
|
|
10
|
+
|
|
11
|
+
- The control never auto-writes. You open the panel, review the per-model diff, then click **应用并写入该 provider** — it writes only that provider's `models` array in the `llm-pi-ai` namespace.
|
|
12
|
+
- Default policy fills **missing** parameters only; tick **覆盖已有值** to also replace differing ones.
|
|
13
|
+
- Nothing leaves the machine except the models.dev catalog request (Host side, optional `HTTPS_PROXY`), and no credentials are ever sent.
|
|
14
|
+
- Models whose id models.dev does not know are reported as 未收录 and left untouched.
|
|
15
|
+
|
|
16
|
+
models.dev model ids may collide across catalog providers; the matcher prefers the record whose provider id relates to the model id's vendor prefix and otherwise takes the first metadata-bearing record.
|
|
17
|
+
|
|
18
|
+
## 中文
|
|
19
|
+
|
|
20
|
+
面向 DeepSeek Harness Web 官方 **设置 → 模型** 页的 models.dev 参数助手。每个已配置的 `llm-pi-ai` provider 卡片内提供 `models.dev 参数` 入口:按该 provider 的模型 id 在 models.dev 官方目录中匹配,预览上下文窗口 / max 输出 / 推理档位,确认后一次性写回该 provider 的 `models` 配置(官方 revision-aware Settings API,仅写 `llm-pi-ai` 命名空间下当前 provider 的数组)。
|
|
21
|
+
|
|
22
|
+
- 默认仅补缺失参数;勾选「覆盖已有值」才替换差异值。
|
|
23
|
+
- Host 只负责拉取并缓存 models.dev 目录(6 小时,支持 `HTTPS_PROXY`),不接触任何 LLM 配置与凭据。
|
|
24
|
+
- models.dev 未收录的模型 id 会明确标记,不写入。
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
From this directory run:
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
npm test
|
|
32
|
+
npm run check
|
|
33
|
+
npm pack --dry-run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// dsh-model-params — client half (official __ModuleLoader__ web bundle)
|
|
2
|
+
//
|
|
3
|
+
// Renders a compact "models.dev 参数" affordance inside every official
|
|
4
|
+
// llm-pi-ai provider card on the Models settings page
|
|
5
|
+
// (settings.models.provider-card, keyed by the pi-ai settings namespace).
|
|
6
|
+
// It reads the current provider profile from the official llm-pi-ai Settings
|
|
7
|
+
// scope, asks the Host for the matching models.dev records, previews the
|
|
8
|
+
// proposed context / max-output / reasoning-effort values per model, and —
|
|
9
|
+
// only on an explicit "应用" click — writes the merged models array back
|
|
10
|
+
// through the revision-aware official Settings API. Default policy fills
|
|
11
|
+
// missing parameters only; "覆盖已有值" also replaces differing ones.
|
|
12
|
+
// The plugin never auto-writes, never touches other providers/namespaces,
|
|
13
|
+
// and never sends credentials anywhere.
|
|
14
|
+
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: 'dsh-model-params',
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
const module = { exports: {} }
|
|
19
|
+
const exports = module.exports
|
|
20
|
+
const React = require('react')
|
|
21
|
+
const e = React.createElement
|
|
22
|
+
|
|
23
|
+
const PI_NS = 'llm-pi-ai'
|
|
24
|
+
const API = '/api/model-params/lookup'
|
|
25
|
+
const FETCH_TIMEOUT_MS = 20000
|
|
26
|
+
const CSS_ID = 'dsh-model-params/style'
|
|
27
|
+
const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
28
|
+
|
|
29
|
+
const CSS = [
|
|
30
|
+
'.dpmRow{display:flex;align-items:center;gap:8px;margin:2px 0}',
|
|
31
|
+
'.dpmButton{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 12px;font-size:12px;line-height:1.5;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-secondary)}',
|
|
32
|
+
'.dpmButton:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed);color:var(--dsw-alias-label-primary)}',
|
|
33
|
+
'.dpmButton:disabled{opacity:.5;cursor:default}',
|
|
34
|
+
'.dpmWrap{position:relative;display:inline-flex}',
|
|
35
|
+
'.dpmPanel{position:absolute;z-index:30;left:0;top:calc(100% + 6px);width:min(480px,calc(100vw - 48px));max-height:min(420px,calc(100vh - 120px));overflow:auto;display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.5}',
|
|
36
|
+
'.dpmPanel h4{margin:0;font-size:13px}',
|
|
37
|
+
'.dpmHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:1.5}',
|
|
38
|
+
'.dpmErr{color:var(--dsw-alias-state-error-primary);margin:0;font-size:11px;word-break:break-all}',
|
|
39
|
+
'.dpmList{display:flex;flex-direction:column;gap:6px}',
|
|
40
|
+
'.dpmItem{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:8px;background:var(--dsw-alias-bg-layer-3)}',
|
|
41
|
+
'.dpmItemHead{display:flex;align-items:baseline;gap:8px}.dpmItemId{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dpmItemTag{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
42
|
+
'.dpmFields{display:flex;flex-direction:column;gap:2px;margin-top:4px;color:var(--dsw-alias-label-secondary)}',
|
|
43
|
+
'.dpmFieldRow{display:flex;gap:8px;flex-wrap:wrap}.dpmFieldRow b{color:var(--dsw-alias-label-secondary);font-weight:600}',
|
|
44
|
+
'.dpmNoMatch{color:var(--dsw-alias-state-warn-primary)}',
|
|
45
|
+
'.dpmFoot{display:flex;align-items:center;gap:10px;justify-content:flex-end;border-top:1px solid var(--dsw-alias-border-l2);margin-top:2px;padding-top:8px}',
|
|
46
|
+
'.dpmFoot label{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);margin-right:auto;cursor:pointer}',
|
|
47
|
+
'.dpmSaved{color:var(--dsw-alias-state-success-primary);margin:0;font-size:11px}',
|
|
48
|
+
'.dpmPrimary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}',
|
|
49
|
+
].join('')
|
|
50
|
+
|
|
51
|
+
function ensureStyles() {
|
|
52
|
+
if (typeof document === 'undefined') return
|
|
53
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']')) return
|
|
54
|
+
const tag = document.createElement('style')
|
|
55
|
+
tag.dataset.pluginCss = CSS_ID
|
|
56
|
+
tag.textContent = CSS
|
|
57
|
+
document.head.appendChild(tag)
|
|
58
|
+
}
|
|
59
|
+
function removeStyles() {
|
|
60
|
+
if (typeof document === 'undefined') return
|
|
61
|
+
const tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']')
|
|
62
|
+
if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const clean = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
66
|
+
const intersectLevels = (values) => (values || []).filter((value) => LEVELS.includes(value))
|
|
67
|
+
|
|
68
|
+
function paramLine(label, value) {
|
|
69
|
+
if (value === undefined || value === null || value === '') return null
|
|
70
|
+
return e('span', { key: label }, label + ': ' + value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ModelParamsExtension(props) {
|
|
74
|
+
const row = props.provider
|
|
75
|
+
const entry = row && row.entry && typeof row.entry === 'object' ? row.entry : row
|
|
76
|
+
const route = entry && (entry.provider || row && row.provider)
|
|
77
|
+
const settingsNs = entry && entry.settingsNs
|
|
78
|
+
const api = props.api
|
|
79
|
+
const scope = props.scope
|
|
80
|
+
const [open, setOpen] = React.useState(false)
|
|
81
|
+
const [busy, setBusy] = React.useState(false)
|
|
82
|
+
const [failed, setFailed] = React.useState('')
|
|
83
|
+
const [saved, setSaved] = React.useState('')
|
|
84
|
+
const [overwrite, setOverwrite] = React.useState(false)
|
|
85
|
+
const [result, setResult] = React.useState(null) // { matched: [], unmatched: [] }
|
|
86
|
+
const ref = React.useRef(null)
|
|
87
|
+
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
if (!open) return undefined
|
|
90
|
+
const outside = (event) => { if (!ref.current || !ref.current.contains(event.target)) setOpen(false) }
|
|
91
|
+
const key = (event) => { if (event.key === 'Escape') setOpen(false) }
|
|
92
|
+
document.addEventListener('mousedown', outside)
|
|
93
|
+
document.addEventListener('keydown', key)
|
|
94
|
+
return () => {
|
|
95
|
+
document.removeEventListener('mousedown', outside)
|
|
96
|
+
document.removeEventListener('keydown', key)
|
|
97
|
+
}
|
|
98
|
+
}, [open])
|
|
99
|
+
|
|
100
|
+
if (!route || settingsNs !== PI_NS) return null
|
|
101
|
+
|
|
102
|
+
const snap = scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : { status: 'unavailable' }
|
|
103
|
+
const value = snap.status === 'ready' && snap.value && typeof snap.value === 'object' ? snap.value : {}
|
|
104
|
+
const profile = value.providers && value.providers[route] && typeof value.providers[route] === 'object' ? value.providers[route] : null
|
|
105
|
+
const models = Array.isArray(profile && profile.models) ? profile.models : []
|
|
106
|
+
const writable = snap.status === 'ready' && snap.writable === true && !!api && !!api.settings
|
|
107
|
+
|
|
108
|
+
async function load() {
|
|
109
|
+
if (!models.length || busy) return
|
|
110
|
+
setBusy(true)
|
|
111
|
+
setFailed('')
|
|
112
|
+
setSaved('')
|
|
113
|
+
setResult(null)
|
|
114
|
+
const controller = new AbortController()
|
|
115
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
116
|
+
try {
|
|
117
|
+
const ids = models.map((model) => model.id).filter(Boolean)
|
|
118
|
+
const response = await fetch(API + '?ids=' + encodeURIComponent(ids.join(',')), { cache: 'no-store', signal: controller.signal })
|
|
119
|
+
const data = await response.json()
|
|
120
|
+
if (!data || data.ok !== true) throw new Error((data && data.error) || 'lookup failed')
|
|
121
|
+
setResult({ matched: data.matched || [], unmatched: data.unmatched || [] })
|
|
122
|
+
} catch (error) {
|
|
123
|
+
setFailed(String((error && error.name === 'AbortError') ? '请求超时' : (error && error.message) || error))
|
|
124
|
+
} finally {
|
|
125
|
+
clearTimeout(timer)
|
|
126
|
+
setBusy(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mergeModel(existing, found, index) {
|
|
131
|
+
const next = { ...existing }
|
|
132
|
+
const from = result.matched.find((match) => match.requested === existing.id) || found
|
|
133
|
+
if (!from) return null
|
|
134
|
+
const apply = (key, foundValue) => {
|
|
135
|
+
if (foundValue === undefined) return
|
|
136
|
+
if (next[key] === undefined || next[key] === null) next[key] = foundValue
|
|
137
|
+
else if (overwrite && next[key] !== foundValue) next[key] = foundValue
|
|
138
|
+
}
|
|
139
|
+
apply('contextWindow', from.context)
|
|
140
|
+
apply('maxTokens', from.maxOutput)
|
|
141
|
+
if (!next.name) next.name = from.name
|
|
142
|
+
const offered = intersectLevels(from.efforts)
|
|
143
|
+
if (offered.length) {
|
|
144
|
+
const current = next.reasoningEfforts && typeof next.reasoningEfforts === 'object' && !Array.isArray(next.reasoningEfforts) ? next.reasoningEfforts : {}
|
|
145
|
+
const merged = { ...current }
|
|
146
|
+
let changed = false
|
|
147
|
+
for (const level of offered) {
|
|
148
|
+
if (merged[level] === undefined || overwrite) {
|
|
149
|
+
if (merged[level] !== level) changed = true
|
|
150
|
+
merged[level] = level
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (overwrite) {
|
|
154
|
+
for (const key of Object.keys(merged)) {
|
|
155
|
+
if (!offered.includes(key) && LEVELS.includes(key)) delete merged[key]
|
|
156
|
+
}
|
|
157
|
+
changed = true
|
|
158
|
+
}
|
|
159
|
+
if (changed || Object.keys(merged).length !== Object.keys(current).length) next.reasoningEfforts = merged
|
|
160
|
+
if (Object.keys(next.reasoningEfforts || {}).length === 0 && !current) delete next.reasoningEfforts
|
|
161
|
+
}
|
|
162
|
+
void index
|
|
163
|
+
return next
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function applyAll() {
|
|
167
|
+
if (!writable || busy || !result) return
|
|
168
|
+
const byRequested = new Map(result.matched.map((match) => [match.requested, match]))
|
|
169
|
+
const nextModels = models.map((model) => {
|
|
170
|
+
const found = byRequested.get(model.id)
|
|
171
|
+
return found ? (mergeModel(model, found) || model) : model
|
|
172
|
+
})
|
|
173
|
+
const touched = nextModels.some((model, index) => model !== models[index])
|
|
174
|
+
if (!touched) { setSaved('没有可写入的变更(仅补缺失且无缺失)。'); return }
|
|
175
|
+
setBusy(true)
|
|
176
|
+
setFailed('')
|
|
177
|
+
setSaved('')
|
|
178
|
+
try {
|
|
179
|
+
const payload = { ns: PI_NS, ops: [{ op: 'set', path: ['providers', route, 'models'], value: nextModels }], ...(snap.revision === undefined ? {} : { expectedRevision: snap.revision }) }
|
|
180
|
+
const response = await api.settings.mutate(payload)
|
|
181
|
+
if (!(response && response.result && response.result.ok)) {
|
|
182
|
+
const detail = response && response.result && response.result.error && response.result.error.message
|
|
183
|
+
throw new Error(detail || '设置被拒绝')
|
|
184
|
+
}
|
|
185
|
+
setSaved('已写入 provider "' + route + '" 的模型参数。')
|
|
186
|
+
setResult(null)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
setFailed(String((error && error.message) || error))
|
|
189
|
+
} finally {
|
|
190
|
+
setBusy(false)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const displayName = entry && entry.displayName ? entry.displayName : route
|
|
195
|
+
const hasModels = models.length > 0
|
|
196
|
+
const summary = result && result.matched ? result.matched.length + '/' + models.length + ' 匹配' : ''
|
|
197
|
+
|
|
198
|
+
return e('div', { className: 'dpmWrap', ref },
|
|
199
|
+
e('button', { type: 'button', className: 'dpmButton', disabled: !hasModels, 'aria-haspopup': 'dialog', 'aria-expanded': open, onClick: () => { setOpen((v) => !v); if (!open) load() } },
|
|
200
|
+
busy ? '查询中…' : ('models.dev 参数' + (summary ? ' · ' + summary : ''))),
|
|
201
|
+
open && e('div', { className: 'dpmPanel', role: 'dialog', 'aria-label': displayName + ' models.dev 参数' },
|
|
202
|
+
e('h4', null, displayName + ' · models.dev 官方参数'),
|
|
203
|
+
failed && e('p', { className: 'dpmErr', role: 'alert' }, failed),
|
|
204
|
+
saved && e('p', { className: 'dpmSaved', role: 'status' }, saved),
|
|
205
|
+
!hasModels && e('p', { className: 'dpmHint' }, '该 provider 的 models 列表为空;先在官方页添加模型条目后再获取参数。'),
|
|
206
|
+
result && e('div', { className: 'dpmList' }, result.matched.length === 0 && result.unmatched.length === 0
|
|
207
|
+
? e('p', { className: 'dpmHint' }, '没有可匹配的模型。')
|
|
208
|
+
: models.map((model) => {
|
|
209
|
+
const found = result.matched.find((match) => match.requested === model.id)
|
|
210
|
+
const currentEfforts = model.reasoningEfforts && typeof model.reasoningEfforts === 'object' && !Array.isArray(model.reasoningEfforts)
|
|
211
|
+
? Object.keys(model.reasoningEfforts).join('/')
|
|
212
|
+
: undefined
|
|
213
|
+
const propose = found ? mergeModel(model, found) : null
|
|
214
|
+
const change = propose && (
|
|
215
|
+
(model.contextWindow === undefined && propose.contextWindow !== undefined) ||
|
|
216
|
+
(model.maxTokens === undefined && propose.maxTokens !== undefined) ||
|
|
217
|
+
(model.contextWindow !== undefined && propose.contextWindow !== undefined && overwrite && model.contextWindow !== propose.contextWindow) ||
|
|
218
|
+
(model.maxTokens !== undefined && propose.maxTokens !== undefined && overwrite && model.maxTokens !== propose.maxTokens) ||
|
|
219
|
+
(propose.reasoningEfforts && JSON.stringify(propose.reasoningEfforts) !== JSON.stringify(model.reasoningEfforts))
|
|
220
|
+
)
|
|
221
|
+
return e('div', { key: model.id, className: 'dpmItem' },
|
|
222
|
+
e('div', { className: 'dpmItemHead' },
|
|
223
|
+
e('span', { className: 'dpmItemId' }, model.id),
|
|
224
|
+
found
|
|
225
|
+
? e('span', { className: 'dpmItemTag' }, found.providerName + ' · ' + found.name + (change ? ' · 将更新' : ' · 完整'))
|
|
226
|
+
: e('span', { className: 'dpmItemTag dpmNoMatch' }, 'models.dev 未收录'),
|
|
227
|
+
),
|
|
228
|
+
found && e('div', { className: 'dpmFields' },
|
|
229
|
+
e('div', { className: 'dpmFieldRow' }, paramLine('上下文', found.context), paramLine('max 输出', found.maxOutput), paramLine('推理档位', found.efforts && found.efforts.length ? found.efforts.join('/') : undefined)),
|
|
230
|
+
e('div', { className: 'dpmFieldRow' },
|
|
231
|
+
paramLine('当前上下文', model.contextWindow),
|
|
232
|
+
paramLine('当前 max', model.maxTokens),
|
|
233
|
+
currentEfforts && paramLine('当前推理', currentEfforts),
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
})),
|
|
238
|
+
result && e('div', { className: 'dpmFoot' },
|
|
239
|
+
e('label', null, e('input', { type: 'checkbox', checked: overwrite, onChange: (ev) => setOverwrite(ev.target.checked) }), '覆盖已有值(默认仅补缺失)'),
|
|
240
|
+
e('button', { type: 'button', className: 'dpmButton dpmPrimary', disabled: !writable || busy, onClick: applyAll }, '应用并写入该 provider'),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function apply(ctx) {
|
|
247
|
+
ensureStyles()
|
|
248
|
+
ctx.effect(() => () => removeStyles(), 'dsh-model-params: styles')
|
|
249
|
+
// Fine-grained remote settings through @deepseek-ai/dsh-api-remotes
|
|
250
|
+
// (ctx.remote); the legacy connection API no longer exists on rc.1.
|
|
251
|
+
const remote = ctx.get('remote')
|
|
252
|
+
const api = remote && remote.settings
|
|
253
|
+
? { settings: { mutate: (payload) => remote.settings.mutate(payload.ns, payload.ops, payload.expectedRevision).then((result) => ({ result })) } }
|
|
254
|
+
: undefined
|
|
255
|
+
const scope = ctx.settingsScope.bind({ namespace: PI_NS })
|
|
256
|
+
return ctx.slots.inject('settings.models.provider-card', () => ctx.slots.register(
|
|
257
|
+
{ name: 'settings.models.provider-card', key: PI_NS },
|
|
258
|
+
(props) => e(ModelParamsExtension, { ...props, api, scope }),
|
|
259
|
+
))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
exports.name = 'dsh-model-params'
|
|
263
|
+
exports.inject = ['slots', 'settingsScope', 'remote', 'remote.settings']
|
|
264
|
+
exports.apply = apply
|
|
265
|
+
return module.exports
|
|
266
|
+
},
|
|
267
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// dsh-model-params — Host half.
|
|
2
|
+
//
|
|
3
|
+
// One loopback/same-origin fenced endpoint:
|
|
4
|
+
// GET /api/model-params/lookup?ids=a,b,c
|
|
5
|
+
// Loads https://models.dev/api.json (6h in-memory cache, optional HTTPS_PROXY
|
|
6
|
+
// fallback), matches each requested model id and returns the metadata-bearing
|
|
7
|
+
// entries (context / max output / reasoning effort values). The Client half
|
|
8
|
+
// renders the official provider-card extension; it never sends credentials
|
|
9
|
+
// and the Host never touches user LLM settings — writes happen on the Client
|
|
10
|
+
// through the official Settings scope of the provider namespace.
|
|
11
|
+
|
|
12
|
+
import { ProxyAgent } from 'undici'
|
|
13
|
+
import { lookupModels, parseModelsDevCatalog } from './modelsdev.js'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-model-params'
|
|
16
|
+
export const inject = ['webServer']
|
|
17
|
+
|
|
18
|
+
const MODELS_DEV_URL = 'https://models.dev/api.json'
|
|
19
|
+
const FETCH_TIMEOUT_MS = 30_000
|
|
20
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
|
21
|
+
const MAX_IDS = 100
|
|
22
|
+
const MAX_ID_LENGTH = 240
|
|
23
|
+
|
|
24
|
+
let cache = null // { at: number, index: Map, fetchedAt: string }
|
|
25
|
+
|
|
26
|
+
function proxyUrl() {
|
|
27
|
+
return process.env.HTTPS_PROXY || process.env.https_proxy
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function fetchCatalog() {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
if (cache && now - cache.at < CACHE_TTL_MS) return cache
|
|
33
|
+
if (cache && cache.pending) {
|
|
34
|
+
await cache.pending
|
|
35
|
+
return cache
|
|
36
|
+
}
|
|
37
|
+
const pending = (async () => {
|
|
38
|
+
const options = {
|
|
39
|
+
headers: { accept: 'application/json' },
|
|
40
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
41
|
+
}
|
|
42
|
+
const proxy = proxyUrl()
|
|
43
|
+
const attempt = async (dispatcher) => {
|
|
44
|
+
const response = await fetch(MODELS_DEV_URL, dispatcher ? { ...options, dispatcher } : options)
|
|
45
|
+
if (!response.ok) throw new Error(`models.dev HTTP ${response.status}`)
|
|
46
|
+
return response.json()
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const document = await attempt()
|
|
50
|
+
const index = parseModelsDevCatalog(document)
|
|
51
|
+
cache = { at: now, index, fetchedAt: new Date().toISOString(), pending: undefined }
|
|
52
|
+
return cache
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (proxy) {
|
|
55
|
+
try {
|
|
56
|
+
const dispatcher = new ProxyAgent(proxy)
|
|
57
|
+
try {
|
|
58
|
+
const document = await attempt(dispatcher)
|
|
59
|
+
cache = { at: now, index: parseModelsDevCatalog(document), fetchedAt: new Date().toISOString(), pending: undefined }
|
|
60
|
+
return cache
|
|
61
|
+
} finally {
|
|
62
|
+
void dispatcher.close().catch(() => {})
|
|
63
|
+
}
|
|
64
|
+
} catch (proxyError) {
|
|
65
|
+
throw new Error(`models.dev fetch failed (direct and via proxy): ${String(error?.message || error)} / ${String(proxyError?.message || proxyError)}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
})()
|
|
71
|
+
if (!cache) cache = { at: now, index: undefined, fetchedAt: undefined, pending }
|
|
72
|
+
try {
|
|
73
|
+
await pending
|
|
74
|
+
} finally {
|
|
75
|
+
if (cache && cache.pending === pending) cache.pending = undefined
|
|
76
|
+
}
|
|
77
|
+
return cache
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseIds(query) {
|
|
81
|
+
const raw = typeof query === 'string' ? query : ''
|
|
82
|
+
if (raw.length > 8000) throw Object.assign(new Error('query too large'), { status: 413 })
|
|
83
|
+
const ids = raw.split(',').map((value) => value.trim()).filter(Boolean)
|
|
84
|
+
if (ids.length > MAX_IDS) throw Object.assign(new Error('too many ids'), { status: 400 })
|
|
85
|
+
if (ids.some((id) => id.length > MAX_ID_LENGTH)) throw Object.assign(new Error('id too long'), { status: 400 })
|
|
86
|
+
return ids
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function originAllowed(req) {
|
|
90
|
+
const origin = req.headers.origin
|
|
91
|
+
if (!origin) return true
|
|
92
|
+
const host = req.headers.host || ''
|
|
93
|
+
const base = /^https?:\/\/([^/]+)/i.exec(origin)
|
|
94
|
+
return !!base && base[1] === host
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hostAllowed(req) {
|
|
98
|
+
let host = String(req.headers.host || '').split(':')[0].toLowerCase()
|
|
99
|
+
host = host.replace(/^\[|\]$/g, '')
|
|
100
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function json(res, status, payload) {
|
|
104
|
+
const body = JSON.stringify(payload)
|
|
105
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
106
|
+
res.end(body)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function apply(ctx) {
|
|
110
|
+
const ws = ctx.webServer
|
|
111
|
+
ctx.effect(() => ws.register({
|
|
112
|
+
kind: 'exact',
|
|
113
|
+
path: '/api/model-params/lookup',
|
|
114
|
+
handler: async (req, res) => {
|
|
115
|
+
if (!originAllowed(req) || !hostAllowed(req)) {
|
|
116
|
+
json(res, 403, { ok: false, error: 'forbidden' })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const ids = parseIds(req.url ? new URL(req.url, 'http://local').searchParams.get('ids') : '')
|
|
121
|
+
const current = await fetchCatalog()
|
|
122
|
+
if (!current.index) throw new Error('models.dev catalog unavailable')
|
|
123
|
+
const result = lookupModels(current.index, ids)
|
|
124
|
+
json(res, 200, {
|
|
125
|
+
ok: true,
|
|
126
|
+
fetchedAt: current.fetchedAt,
|
|
127
|
+
matched: result.matched,
|
|
128
|
+
unmatched: result.unmatched,
|
|
129
|
+
})
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const status = Number(error && error.status) || 500
|
|
132
|
+
json(res, status, { ok: false, error: String((error && error.message) || error) })
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}), 'dsh-model-params: lookup route')
|
|
136
|
+
}
|
package/lib/modelsdev.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// dsh-model-params — pure models.dev catalog parsing and matching helpers.
|
|
2
|
+
//
|
|
3
|
+
// models.dev/api.json shape (defensive reads only):
|
|
4
|
+
// { providers: { <id>: { id, name, models: [ { id, name, limit?: { context?, output? },
|
|
5
|
+
// reasoning_options?: [ { type: 'effort', values?: string[] } ] } ] } } }
|
|
6
|
+
//
|
|
7
|
+
// A requested model id may carry a vendor prefix ("z-ai/glm-5.3-flash",
|
|
8
|
+
// "deepseek/deepseek-v4-flash"). Matching therefore tries the exact id first
|
|
9
|
+
// and then the segment after the last "/". Reasoning-effort values offered by
|
|
10
|
+
// models.dev are intersected with the pi-ai THINKING_LEVELS key space
|
|
11
|
+
// (off/minimal/low/medium/high/xhigh/max) so a write can never fail schema
|
|
12
|
+
// validation.
|
|
13
|
+
|
|
14
|
+
export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
15
|
+
|
|
16
|
+
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0
|
|
17
|
+
const clean = (value) => nonEmpty(value) ? value.trim() : undefined
|
|
18
|
+
const positiveInt = (value) => Number.isSafeInteger(value) && value > 0 ? value : undefined
|
|
19
|
+
|
|
20
|
+
/** Parse one models.dev catalog document into a detached match index. */
|
|
21
|
+
export function parseModelsDevCatalog(document) {
|
|
22
|
+
const root = document && typeof document === 'object' ? document : {}
|
|
23
|
+
const providers = root.providers && typeof root.providers === 'object' && !Array.isArray(root.providers) ? root.providers : {}
|
|
24
|
+
const byId = new Map()
|
|
25
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
26
|
+
if (!provider || typeof provider !== 'object') continue
|
|
27
|
+
const providerName = clean(provider.name) || clean(provider.id) || providerId
|
|
28
|
+
const models = Array.isArray(provider.models) ? provider.models : []
|
|
29
|
+
for (const model of models) {
|
|
30
|
+
if (!model || typeof model !== 'object') continue
|
|
31
|
+
const modelId = clean(model.id)
|
|
32
|
+
if (!modelId) continue
|
|
33
|
+
const limit = model.limit && typeof model.limit === 'object' ? model.limit : {}
|
|
34
|
+
const context = positiveInt(limit.context)
|
|
35
|
+
const output = positiveInt(limit.output)
|
|
36
|
+
let efforts = []
|
|
37
|
+
const options = Array.isArray(model.reasoning_options) ? model.reasoning_options : []
|
|
38
|
+
for (const option of options) {
|
|
39
|
+
if (!option || option.type !== 'effort' || !Array.isArray(option.values)) continue
|
|
40
|
+
for (const value of option.values) {
|
|
41
|
+
const id = clean(value)
|
|
42
|
+
if (id && !efforts.includes(id)) efforts.push(id)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (context === undefined && output === undefined && efforts.length === 0) continue
|
|
46
|
+
const key = modelId.toLowerCase()
|
|
47
|
+
const entry = {
|
|
48
|
+
providerId,
|
|
49
|
+
providerName,
|
|
50
|
+
modelId,
|
|
51
|
+
name: clean(model.name) || modelId,
|
|
52
|
+
...(context !== undefined ? { context } : {}),
|
|
53
|
+
...(output !== undefined ? { maxOutput: output } : {}),
|
|
54
|
+
...(efforts.length ? { efforts } : {}),
|
|
55
|
+
}
|
|
56
|
+
const list = byId.get(key)
|
|
57
|
+
if (list) list.push(entry)
|
|
58
|
+
else byId.set(key, [entry])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return byId
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Prefer the entry whose provider matches a hint; otherwise the first. */
|
|
65
|
+
function pickEntry(entries, hint) {
|
|
66
|
+
if (entries.length <= 1) return entries[0]
|
|
67
|
+
const lower = hint ? String(hint).toLowerCase() : ''
|
|
68
|
+
if (lower) {
|
|
69
|
+
const preferred = entries.find((entry) =>
|
|
70
|
+
entry.providerId.toLowerCase().includes(lower) || lower.includes(entry.providerId.toLowerCase()))
|
|
71
|
+
if (preferred) return preferred
|
|
72
|
+
}
|
|
73
|
+
return entries[0]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Look up requested model ids. Returns entries only for ids with at least one
|
|
78
|
+
* metadata-bearing models.dev record; every other id is reported as unmatched.
|
|
79
|
+
* @param {Map<string, object[]>} index - output of parseModelsDevCatalog.
|
|
80
|
+
* @param {string[]} ids - configured model ids (vendor prefixes allowed).
|
|
81
|
+
* @param {(id: string) => string|undefined} hintFor - optional provider hint per id.
|
|
82
|
+
* @returns {{ matched: object[], unmatched: string[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function lookupModels(index, ids, hintFor = () => undefined) {
|
|
85
|
+
const matched = []
|
|
86
|
+
const unmatched = []
|
|
87
|
+
for (const raw of ids || []) {
|
|
88
|
+
const modelId = clean(raw)
|
|
89
|
+
if (!modelId) continue
|
|
90
|
+
const exact = index.get(modelId.toLowerCase())
|
|
91
|
+
const base = modelId.slice(modelId.lastIndexOf('/') + 1)
|
|
92
|
+
const byBase = base !== modelId ? index.get(base.toLowerCase()) : undefined
|
|
93
|
+
const entries = byBase && byBase.length && (!exact || byBase.length < exact.length) ? byBase : exact
|
|
94
|
+
if (!entries || !entries.length) {
|
|
95
|
+
unmatched.push(modelId)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
matched.push({ requested: modelId, ...pickEntry(entries, hintFor(modelId)) })
|
|
99
|
+
}
|
|
100
|
+
return { matched, unmatched }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Restrict raw models.dev effort values to the pi-ai thinking-level space. */
|
|
104
|
+
export function intersectEfforts(efforts, levels = THINKING_LEVELS) {
|
|
105
|
+
return (efforts || []).filter((value) => levels.includes(value))
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-model-params",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "EN: models.dev parameter assistant for the official DeepSeek Harness Models page: per-provider one-click context / max-output / reasoning-effort fill. ZH: 官方模型设置页的 models.dev 参数助手:按 provider 一键补全上下文、max 输出与推理档位。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"check": "node --check lib/index.js && node --check lib/modelsdev.js && node --check lib/client.js",
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"undici": "^7.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dsh": {
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web",
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"repository": { "type": "git", "url": "git+https://github.com/YrracOwl/dsh-model-params.git" },
|
|
37
|
+
"homepage": "https://github.com/YrracOwl/dsh-model-params#readme",
|
|
38
|
+
"bugs": { "url": "https://github.com/YrracOwl/dsh-model-params/issues" },
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
}
|
|
43
|
+
}
|