dsh-openrouter-free 0.2.0 → 0.2.2
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/README.md +4 -3
- package/lib/client.js +44 -5
- package/lib/index.js +98 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,17 +4,18 @@
|
|
|
4
4
|

|
|
5
5
|
[](https://www.npmjs.com/package/dsh-openrouter-free)
|
|
6
6
|
|
|
7
|
-
OpenRouter 免费模型面板(DeepSeek Harness / DSH 持久插件)。特性:**综合能力 1-5★ 分级标星 · 最优三优先排序 ·
|
|
7
|
+
OpenRouter 免费模型面板(DeepSeek Harness / DSH 持久插件)。特性:**综合能力 1-5★ 分级标星 · 最优三优先排序 · 一键切换(会话级 + 全局双写,推理档自动预校验)**。
|
|
8
8
|
|
|
9
9
|
自动拉取 [openrouter.ai](https://openrouter.ai/api/v1/models) 的实时免费模型清单(输入/输出单价均为 $0),一键安装进本机 `openrouter` 路由并设为当前对话模型——免去手动编辑 `settings.yaml` 配置模型的麻烦。
|
|
10
10
|
|
|
11
11
|
## 功能
|
|
12
12
|
|
|
13
13
|
- **免费清单面板**:GUI 会话视图新增「免费模型」标签页,实时列出 OpenRouter 全部免费模型(名称 / ID / 上下文窗口 / 是否支持图片与推理),10 分钟内存缓存,可手动强刷。
|
|
14
|
-
-
|
|
14
|
+
- **一键切换(会话级 + 全局双写)**:点击任意行 → 该模型被 upsert 进 `llm-pi-ai.providers.openrouter.models`,同时经原生 `sessionController.selectModel()` 写入**当前会话**的模型选择并更新 `agent-default-model`。**下一次请求即生效,无需重启**(对有请求历史的会话,只改全局默认不够——三层优先级 pending→lastUsed→default 会压住它)。
|
|
15
|
+
- **推理档自动预校验**:切换时从高到低试选该模型真正支持的档位(low/medium/high);不支持推理的模型自动摘掉 `reasoningEffort`,避免请求以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
|
16
|
+
- **凭据面板**:内置 OpenRouter API Key 设置入口,缺密钥时直接提示补填。
|
|
15
17
|
- **全部装进选择器**:一次把所有免费模型写入路由,之后也能用 DSH 自带模型选择器切换。
|
|
16
18
|
- **安全保留手工条目**:同步/切换单个模型时,所有非免费的手工条目(如 `stealth/ox-alpha` 的精调条目)原样保留;「全部装进选择器」也只刷新免费条目。
|
|
17
|
-
- **推理档自适应**:不支持推理的模型切换时会自动摘掉 `reasoningEffort`,避免请求以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
|
18
19
|
|
|
19
20
|
## 前置条件
|
|
20
21
|
|
package/lib/client.js
CHANGED
|
@@ -47,6 +47,12 @@ window.__ModuleLoader__.load({
|
|
|
47
47
|
+ '.orf-toast.err{border-color:#f85149;color:#f85149;}'
|
|
48
48
|
+ '.orf-empty{padding:40px;text-align:center;color:#484f58;font-size:13px;}'
|
|
49
49
|
+ '.orb-spin{animation:orfSpin 1s linear infinite;}@keyframes orfSpin{to{transform:rotate(360deg)}}'
|
|
50
|
+
+ '.orf-keywarn{flex:none;display:flex;flex-direction:column;gap:8px;padding:10px 14px;background:rgba(210,153,34,.08);border:1px solid rgba(210,153,34,.45);border-radius:10px;font-size:12px;color:#d29922;}'
|
|
51
|
+
+ '.orf-keywarn-title{font-weight:600;}'
|
|
52
|
+
+ '.orf-keyrow{display:flex;gap:8px;}'
|
|
53
|
+
+ '.orf-keyinput{flex:1;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:7px 12px;font-size:12px;color:#e6edf3;outline:none;font-family:"JetBrains Mono",monospace;}'
|
|
54
|
+
+ '.orf-keyinput:focus{border-color:rgba(210,153,34,.6);}'
|
|
55
|
+
+ '.orf-keyhint{font-size:11px;color:#8b949e;}'
|
|
50
56
|
|
|
51
57
|
function injectStyles(css) {
|
|
52
58
|
if (typeof document === 'undefined') return
|
|
@@ -94,6 +100,7 @@ window.__ModuleLoader__.load({
|
|
|
94
100
|
{ name: 'conversation.view', id: 'dsh-openrouter-free', label: '免费模型', order: 9 },
|
|
95
101
|
(props) => {
|
|
96
102
|
function Panel() {
|
|
103
|
+
const sessionId = props && props.sessionId ? String(props.sessionId) : ''
|
|
97
104
|
const [models, setModels] = React.useState(null)
|
|
98
105
|
const [cur, setCur] = React.useState(null)
|
|
99
106
|
const [loading, setLoading] = React.useState(true)
|
|
@@ -102,9 +109,22 @@ window.__ModuleLoader__.load({
|
|
|
102
109
|
const [query, setQuery] = React.useState('')
|
|
103
110
|
const [refreshing, setRefreshing] = React.useState(false)
|
|
104
111
|
const [switching, setSwitching] = React.useState('')
|
|
112
|
+
const [keyInfo, setKeyInfo] = React.useState(null)
|
|
113
|
+
const [keyDraft, setKeyDraft] = React.useState('')
|
|
114
|
+
const [savingKey, setSavingKey] = React.useState(false)
|
|
105
115
|
|
|
106
116
|
function showToast(msg, isErr) { setToast({ msg: msg, err: !!isErr }); setTimeout(() => setToast(''), 2600) }
|
|
107
117
|
function loadCurrent() { api('current').then(j => setCur(j.default || {})).catch(() => {}) }
|
|
118
|
+
function loadKeyStatus() { api('key.status').then(j => setKeyInfo(j)).catch(() => {}) }
|
|
119
|
+
function saveKey() {
|
|
120
|
+
const keyDraftValue = keyDraft.trim()
|
|
121
|
+
if (!keyDraftValue) { showToast('请先粘贴 API Key', true); return }
|
|
122
|
+
setSavingKey(true)
|
|
123
|
+
api('key.set', { key: keyDraftValue })
|
|
124
|
+
.then(j => { setKeyDraft(''); return loadKeyStatus().then(() => showToast(j.note || '已保存')) })
|
|
125
|
+
.catch(e => showToast(String(e.message || e), true))
|
|
126
|
+
.then(() => setSavingKey(false))
|
|
127
|
+
}
|
|
108
128
|
function loadList(refresh) {
|
|
109
129
|
setRefreshing(true); if (refresh) setError('')
|
|
110
130
|
api('free.list', { refresh: !!refresh })
|
|
@@ -112,12 +132,17 @@ window.__ModuleLoader__.load({
|
|
|
112
132
|
.catch(e => setError(String(e.message || e)))
|
|
113
133
|
.then(() => { setRefreshing(false); setLoading(false); })
|
|
114
134
|
}
|
|
115
|
-
React.useEffect(() => { loadCurrent(); loadList(false) }, [])
|
|
135
|
+
React.useEffect(() => { loadCurrent(); loadKeyStatus(); loadList(false) }, [])
|
|
116
136
|
|
|
117
137
|
function switchTo(m) {
|
|
118
138
|
setSwitching(m.id); setError('')
|
|
119
|
-
api('model.use', { id: m.id })
|
|
120
|
-
.then(j => {
|
|
139
|
+
api('model.use', { id: m.id, sessionId: sessionId })
|
|
140
|
+
.then(j => {
|
|
141
|
+
showToast(j.sessionApplied
|
|
142
|
+
? '已切换 → ' + m.id + '(本会话与全局默认都已生效)'
|
|
143
|
+
: '已切换 → ' + m.id + '(' + (j.note || '下一次请求生效') + ')')
|
|
144
|
+
return loadCurrent()
|
|
145
|
+
})
|
|
121
146
|
.catch(e => { setError(String(e.message || e)); showToast(String(e.message || e), true) })
|
|
122
147
|
.then(() => setSwitching(''))
|
|
123
148
|
}
|
|
@@ -189,6 +214,20 @@ window.__ModuleLoader__.load({
|
|
|
189
214
|
|
|
190
215
|
const rows = list.map(makeRow)
|
|
191
216
|
|
|
217
|
+
const keyBar = keyInfo && keyInfo.configured === false
|
|
218
|
+
? React.createElement('div', { className: 'orf-keywarn' },
|
|
219
|
+
React.createElement('div', { className: 'orf-keywarn-title' }, '⚠ OpenRouter API Key 未配置——免费模型也需要 Key 认证,否则发消息报 MISSING_CREDENTIAL'),
|
|
220
|
+
React.createElement('div', { className: 'orf-keyrow' },
|
|
221
|
+
React.createElement('input', {
|
|
222
|
+
className: 'orf-keyinput', type: 'password',
|
|
223
|
+
placeholder: '粘贴 OpenRouter API Key(sk-or-v1-…)', value: keyDraft,
|
|
224
|
+
onChange: (e) => setKeyDraft(e.target.value),
|
|
225
|
+
}),
|
|
226
|
+
React.createElement('button', { className: 'orf-btn primary', onClick: saveKey, disabled: savingKey },
|
|
227
|
+
savingKey ? '保存中…' : '保存 Key')),
|
|
228
|
+
React.createElement('div', { className: 'orf-keyhint' }, '在 openrouter.ai/keys 免费注册获取;Key 只存进本机凭据库,保存后面板警告自动消失,无需重启。'))
|
|
229
|
+
: null
|
|
230
|
+
|
|
192
231
|
const body = loading
|
|
193
232
|
? React.createElement('div', { className: 'orf-empty' }, '正在拉取 OpenRouter 免费清单…')
|
|
194
233
|
: error
|
|
@@ -197,8 +236,8 @@ window.__ModuleLoader__.load({
|
|
|
197
236
|
|
|
198
237
|
const toastEl = toast ? React.createElement('div', { className: 'orf-toast' + (toast.err ? ' err' : '') }, toast.msg) : null
|
|
199
238
|
|
|
200
|
-
return React.createElement('div', { className: 'orf-wrap' }, header, curBar, toolbar, body,
|
|
201
|
-
React.createElement('div', { className: 'orf-note' }, '💡
|
|
239
|
+
return React.createElement('div', { className: 'orf-wrap' }, header, curBar, keyBar, toolbar, body,
|
|
240
|
+
React.createElement('div', { className: 'orf-note' }, '💡 点击任意行立即切换为对话模型(本会话 + 全局默认一起切,免重启);“全部装进选择器”把所有免费模型写入 openrouter 路由。免费模型有平台速率限制。'),
|
|
202
241
|
toastEl
|
|
203
242
|
)
|
|
204
243
|
}
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const MODELS_URL = 'https://openrouter.ai/api/v1/models'
|
|
|
27
27
|
const ROUTE_NS = 'llm-pi-ai'
|
|
28
28
|
const DEFAULT_NS = 'agent-default-model'
|
|
29
29
|
const PROVIDER_KEY = 'openrouter'
|
|
30
|
+
const KEY_REF = 'OPENROUTER_API_KEY'
|
|
30
31
|
const CACHE_TTL_MS = 10 * 60 * 1000
|
|
31
32
|
|
|
32
33
|
// ---------- OpenRouter 模型 → DSH llm-pi-ai 条目映射 ----------
|
|
@@ -169,13 +170,92 @@ export function apply(ctx) {
|
|
|
169
170
|
next.push(row.entry)
|
|
170
171
|
await s.update(ROUTE_NS, { providers: { [PROVIDER_KEY]: { models: next } } })
|
|
171
172
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
// 选一个该模型真正支持的推理档:mapModel 给推理模型只声明 {low,medium,high}
|
|
174
|
+
// (无 max),故从高到低试 resolveCallConfig 挑第一个通过的档;非推理模型直接
|
|
175
|
+
// 省略 reasoningEffort(设任何档都会触发 UNSUPPORTED_REASONING_EFFORT)。
|
|
176
|
+
const llm = ctx.get('llm')
|
|
177
|
+
const candidates = row.reasoning ? ['high', 'medium', 'low'] : [undefined]
|
|
178
|
+
let chosenEffort
|
|
179
|
+
let resolvedOk = false
|
|
180
|
+
if (llm && typeof llm.resolveCallConfig === 'function') {
|
|
181
|
+
for (const eff of candidates) {
|
|
182
|
+
const cfg = { provider: PROVIDER_KEY, model: id }
|
|
183
|
+
if (eff !== undefined) cfg.reasoningEffort = eff
|
|
184
|
+
try { await llm.resolveCallConfig(cfg); chosenEffort = eff; resolvedOk = true; break } catch { /* 试下一档 */ }
|
|
185
|
+
}
|
|
186
|
+
if (!resolvedOk) throw new Error('模型预校验失败(未切换):openrouter/' + id + ' 无法解析为可用调用配置')
|
|
187
|
+
} else {
|
|
188
|
+
chosenEffort = row.reasoning ? 'high' : undefined
|
|
189
|
+
}
|
|
190
|
+
const selected = { provider: PROVIDER_KEY, model: id }
|
|
191
|
+
if (chosenEffort !== undefined) selected.reasoningEffort = chosenEffort
|
|
192
|
+
await s.replace(DEFAULT_NS, selected)
|
|
193
|
+
|
|
194
|
+
// 会话级同步:已有请求历史的会话,其模型锁在会话投影(modelSelection
|
|
195
|
+
// 事件)里,只改全局默认对该会话无效。原生底部选择器走的是
|
|
196
|
+
// sessionController.selectModel(commands.ts L118)= resolveAgent(含冷会话
|
|
197
|
+
// resume)→ selectForNextRequest(写 model/selection 会话事件 + 内存
|
|
198
|
+
// selection 双写)→ agentDefaultModel.saveSelection。
|
|
199
|
+
// ⚠️ 不能用 ctx.get('agents').selectForNextRequest —— 公开的 agents 服务是
|
|
200
|
+
// core/agent 的 AgentRegistry(只有 get/list/create/resume…),
|
|
201
|
+
// selectForNextRequest 在 session-controller 的内部类
|
|
202
|
+
// ApiSessionAgentController 上,不在任何公开服务接口里(2026-09-03 问题一
|
|
203
|
+
// 根因:v0.2.1 调了这个不存在的幻影 API,会话级那一笔从未落地)。
|
|
204
|
+
// 这里直调 sessionController.selectModel 公开方法,与原生切换行为完全一致。
|
|
205
|
+
const sessionId = String((args && args.sessionId) || '')
|
|
206
|
+
let sessionApplied = false
|
|
207
|
+
let sessionNote = ''
|
|
208
|
+
if (sessionId) {
|
|
209
|
+
const controller = ctx.get('sessionController')
|
|
210
|
+
if (controller && typeof controller.selectModel === 'function') {
|
|
211
|
+
try {
|
|
212
|
+
await controller.selectModel(Object.assign({ sessionId: sessionId }, selected))
|
|
213
|
+
sessionApplied = true
|
|
214
|
+
} catch (error) {
|
|
215
|
+
sessionNote = '会话级写入失败,已改全局默认(该会话请用底部模型选择器重选一次):' + fmtErr(error)
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
sessionNote = 'sessionController 服务不可用,仅改全局默认'
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
sessionNote = '未收到会话 id(前端未取到 sessionId),仅改全局默认'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
applied: selected,
|
|
227
|
+
installedCount: next.length,
|
|
228
|
+
sessionApplied,
|
|
229
|
+
sessionIdSeen: sessionId !== '',
|
|
230
|
+
...(sessionNote ? { note: sessionNote } : { note: '下一次请求即生效,无需重启' }),
|
|
231
|
+
}
|
|
232
|
+
},
|
|
177
233
|
|
|
178
|
-
|
|
234
|
+
// ---------- OpenRouter API Key(免费模型也必须带 key 认证) ----------
|
|
235
|
+
|
|
236
|
+
async 'key.status'() {
|
|
237
|
+
const cred = ctx.get('credentials')
|
|
238
|
+
if (!cred) throw new Error('credentials 服务不可用')
|
|
239
|
+
const info = await cred.describe(KEY_REF)
|
|
240
|
+
return { ok: true, configured: info.configured === true, source: info.source, writable: info.writable !== false }
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async 'key.set'(args) {
|
|
244
|
+
const value = String((args && args.key) || '').trim()
|
|
245
|
+
if (!value) throw new Error('key 为空')
|
|
246
|
+
if (!/^[\x21-\x7E]+$/.test(value)) throw new Error('key 含非法字符(只允许可见 ASCII)')
|
|
247
|
+
if (value.length < 20) throw new Error('key 太短,不像有效的 API Key')
|
|
248
|
+
const cred = ctx.get('credentials')
|
|
249
|
+
if (!cred) throw new Error('credentials 服务不可用')
|
|
250
|
+
await cred.set(KEY_REF, value)
|
|
251
|
+
return { ok: true, note: 'OPENROUTER_API_KEY 已写入凭据库,下一次请求即生效' }
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async 'key.unset'() {
|
|
255
|
+
const cred = ctx.get('credentials')
|
|
256
|
+
if (!cred) throw new Error('credentials 服务不可用')
|
|
257
|
+
await cred.unset(KEY_REF)
|
|
258
|
+
return { ok: true }
|
|
179
259
|
},
|
|
180
260
|
|
|
181
261
|
async 'sync.all'() {
|
|
@@ -205,7 +285,19 @@ export function apply(ctx) {
|
|
|
205
285
|
},
|
|
206
286
|
}
|
|
207
287
|
|
|
288
|
+
// host-auth 只包 /api 前缀,自定义前缀须自建 loopback 围栏
|
|
289
|
+
//(本端点可改凭据,绝不能对局域网开放)。
|
|
290
|
+
function isLoopback(req) {
|
|
291
|
+
const addr = String((req.socket && req.socket.remoteAddress) || '')
|
|
292
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
|
|
293
|
+
}
|
|
294
|
+
|
|
208
295
|
function handle(req, res) {
|
|
296
|
+
if (!isLoopback(req)) {
|
|
297
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
298
|
+
res.end(JSON.stringify({ ok: false, error: 'loopback only' }))
|
|
299
|
+
return
|
|
300
|
+
}
|
|
209
301
|
let body = ''
|
|
210
302
|
req.on('data', (chunk) => { body += chunk })
|
|
211
303
|
req.on('end', () => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-openrouter-free",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "OpenRouter 免费模型面板:自动拉取实时免费模型清单(综合能力 1-5★ 分级、最优优先排序),一键安装进 openrouter
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "OpenRouter 免费模型面板:自动拉取实时免费模型清单(综合能力 1-5★ 分级、最优优先排序),一键安装进 openrouter 路由并切换当前对话模型(全局默认 + 会话级双写,推理档自动选该模型支持的最高档),免手动配置。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh",
|