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 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
@@ -0,0 +1,5 @@
1
+ # dsh-model-params bundle patch.
2
+ # Host-plane single instance: do not copy this row into an agent preset.
3
+ - insert:
4
+ - id: model-params
5
+ name: 'dsh-model-params'
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
+ }
@@ -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
+ }