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 CHANGED
@@ -4,17 +4,18 @@
4
4
  ![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-8b5cf6)
5
5
  [![npm](https://img.shields.io/npm/v/dsh-openrouter-free)](https://www.npmjs.com/package/dsh-openrouter-free)
6
6
 
7
- OpenRouter 免费模型面板(DeepSeek Harness / DSH 持久插件)。特性:**综合能力 1-5★ 分级标星 · 最优三优先排序 · 一键切换(推理档自动 max)**。
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
- - **一键切换**:点击任意行 → 该模型被 upsert 进 `llm-pi-ai.providers.openrouter.models`,同时 `agent-default-model` 指向它。**下一次请求即生效,无需重启**。
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 => { showToast('已切换 → ' + m.id + '(下一次请求生效)'); return loadCurrent() })
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' }, '💡 点击任意行立即切换为对话模型(免重启);“全部装进选择器”把所有免费模型写入 openrouter 路由。免费模型有平台速率限制。'),
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
- // agent-default-model 整节替换:不支持推理的模型必须摘掉 reasoningEffort,
173
- // 否则请求会在网络 I/O 前以 UNSUPPORTED_REASONING_EFFORT 失败。
174
- const doc = { provider: PROVIDER_KEY, model: id }
175
- if (row.reasoning) doc.reasoningEffort = 'max'
176
- await s.replace(DEFAULT_NS, doc)
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
- return { ok: true, applied: doc, installedCount: next.length, note: '下一次请求即生效,无需重启' }
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.0",
4
- "description": "OpenRouter 免费模型面板:自动拉取实时免费模型清单(综合能力 1-5★ 分级、最优优先排序),一键安装进 openrouter 路由并设为当前对话模型(推理档默认 max),免手动配置。",
3
+ "version": "0.2.2",
4
+ "description": "OpenRouter 免费模型面板:自动拉取实时免费模型清单(综合能力 1-5★ 分级、最优优先排序),一键安装进 openrouter 路由并切换当前对话模型(全局默认 + 会话级双写,推理档自动选该模型支持的最高档),免手动配置。",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",