dsh-openrouter-free 0.2.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 +50 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +213 -0
- package/lib/index.js +232 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jiang12345-code
|
|
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,50 @@
|
|
|
1
|
+
# dsh-openrouter-free
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jiang12345-code/dsh-openrouter-free/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+
[](https://www.npmjs.com/package/dsh-openrouter-free)
|
|
6
|
+
|
|
7
|
+
OpenRouter 免费模型面板(DeepSeek Harness / DSH 持久插件)。特性:**综合能力 1-5★ 分级标星 · 最优三优先排序 · 一键切换(推理档自动 max)**。
|
|
8
|
+
|
|
9
|
+
自动拉取 [openrouter.ai](https://openrouter.ai/api/v1/models) 的实时免费模型清单(输入/输出单价均为 $0),一键安装进本机 `openrouter` 路由并设为当前对话模型——免去手动编辑 `settings.yaml` 配置模型的麻烦。
|
|
10
|
+
|
|
11
|
+
## 功能
|
|
12
|
+
|
|
13
|
+
- **免费清单面板**:GUI 会话视图新增「免费模型」标签页,实时列出 OpenRouter 全部免费模型(名称 / ID / 上下文窗口 / 是否支持图片与推理),10 分钟内存缓存,可手动强刷。
|
|
14
|
+
- **一键切换**:点击任意行 → 该模型被 upsert 进 `llm-pi-ai.providers.openrouter.models`,同时 `agent-default-model` 指向它。**下一次请求即生效,无需重启**。
|
|
15
|
+
- **全部装进选择器**:一次把所有免费模型写入路由,之后也能用 DSH 自带模型选择器切换。
|
|
16
|
+
- **安全保留手工条目**:同步/切换单个模型时,所有非免费的手工条目(如 `stealth/ox-alpha` 的精调条目)原样保留;「全部装进选择器」也只刷新免费条目。
|
|
17
|
+
- **推理档自适应**:不支持推理的模型切换时会自动摘掉 `reasoningEffort`,避免请求以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
|
18
|
+
|
|
19
|
+
## 前置条件
|
|
20
|
+
|
|
21
|
+
- 本机 `settings.yaml` 已有 `llm-pi-ai.providers.openrouter` 路由(`baseURL: https://openrouter.ai/api/v1`)+ 环境变量 `OPENROUTER_API_KEY`(使用免费模型也需要密钥)。
|
|
22
|
+
- 拉取清单本身是公开 API,无需密钥。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
# 源码在本仓库;把包复制进运行中的 profile 并注册 bundle
|
|
28
|
+
Copy-Item -Recurse -Force . "C:\Users\73618\.dsh\profiles\web\node_modules\dsh-openrouter-free"
|
|
29
|
+
# 在 profile 的 package.json 里给 dsh.profile.bundles 追加 "dsh-openrouter-free"
|
|
30
|
+
# 然后重启 DSH(host 改动需重启;client 改动硬刷新即可)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## HTTP API(POST /__orfree/api,body: {method, args})
|
|
34
|
+
|
|
35
|
+
| method | 说明 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `free.list({refresh})` | 免费清单(缓存 10 分钟,refresh 强刷) |
|
|
38
|
+
| `current()` | 当前 agent-default-model 与已装入的模型 id 列表 |
|
|
39
|
+
| `model.use({id})` | 安装单个免费模型并设为当前对话模型 |
|
|
40
|
+
| `sync.all()` | 全部免费模型装入 openrouter 路由 |
|
|
41
|
+
| `free.remove({id})` | 移除某个免费模型(当前选中者拒绝) |
|
|
42
|
+
|
|
43
|
+
## 设计依据
|
|
44
|
+
|
|
45
|
+
- DSH llm-pi-ai 适配器的 `models` 列表即"替换该路由 catalog",且 settings 分节更新在下一次请求生效、无需重启(见 `packages/llm/llm-pi-ai/README.zh.md`)。
|
|
46
|
+
- 该适配器明确声明"路由的 catalog 不会自我刷新"——本插件就是补上这个自动化缺口。
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-openrouter-free',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} }
|
|
5
|
+
var exports = module.exports
|
|
6
|
+
var React = require('react')
|
|
7
|
+
|
|
8
|
+
var CSS = ''
|
|
9
|
+
+ '.orf-wrap{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e6edf3;background:#0d1117;padding:16px 20px;height:100%;display:flex;flex-direction:column;gap:12px;min-height:0;}'
|
|
10
|
+
+ '.orf-top{display:flex;align-items:center;justify-content:space-between;flex:none;}'
|
|
11
|
+
+ '.orf-brand{display:flex;align-items:center;gap:8px;}'
|
|
12
|
+
+ '.orf-dot{width:8px;height:8px;border-radius:50%;background:#3fb950;box-shadow:0 0 8px rgba(63,185,80,.6);}'
|
|
13
|
+
+ '.orf-title{font-size:13px;font-weight:600;}'
|
|
14
|
+
+ '.orf-sub{font-size:11px;color:#8b949e;margin-left:4px;}'
|
|
15
|
+
+ '.orf-actions{display:flex;gap:8px;align-items:center;}'
|
|
16
|
+
+ '.orf-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:#161b22;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-size:12px;cursor:pointer;transition:all .15s;}'
|
|
17
|
+
+ '.orf-btn:hover{background:#1c2128;border-color:#484f58;}'
|
|
18
|
+
+ '.orf-btn:disabled{opacity:.5;cursor:default;}'
|
|
19
|
+
+ '.orf-btn.primary{background:#238636;border-color:#2ea043;}'
|
|
20
|
+
+ '.orf-btn.primary:hover{background:#2ea043;}'
|
|
21
|
+
+ '.orf-btn svg{width:13px;height:13px;}'
|
|
22
|
+
+ '.orf-cur{flex:none;display:flex;align-items:center;gap:10px;padding:9px 14px;background:#161b22;border:1px solid #30363d;border-radius:10px;font-size:12px;}'
|
|
23
|
+
+ '.orf-cur-label{color:#8b949e;}'
|
|
24
|
+
+ '.orf-cur-model{font-family:"JetBrains Mono",monospace;color:#58a6ff;font-weight:600;}'
|
|
25
|
+
+ '.orf-cur-effort{color:#d29922;font-family:"JetBrains Mono",monospace;font-size:11px;}'
|
|
26
|
+
+ '.orf-toolbar{display:flex;gap:10px;align-items:center;flex:none;}'
|
|
27
|
+
+ '.orf-search{flex:1;max-width:360px;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:8px 12px;font-size:12px;color:#e6edf3;outline:none;}'
|
|
28
|
+
+ '.orf-search:focus{border-color:rgba(88,166,255,.5);}'
|
|
29
|
+
+ '.orf-meta{font-size:11px;color:#484f58;font-family:"JetBrains Mono",monospace;}'
|
|
30
|
+
+ '.orf-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:6px;min-height:0;padding-right:4px;}'
|
|
31
|
+
+ '.orf-row{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#161b22;border:1px solid #21262d;border-radius:10px;cursor:pointer;transition:all .12s;}'
|
|
32
|
+
+ '.orf-row:hover{background:#1c2128;border-color:#30363d;}'
|
|
33
|
+
+ '.orf-row.active{border-color:rgba(63,185,80,.55);background:rgba(63,185,80,.08);}'
|
|
34
|
+
+ '.orf-row.switching{opacity:.6;pointer-events:none;}'
|
|
35
|
+
+ '.orf-row-main{flex:1;min-width:0;}'
|
|
36
|
+
+ '.orf-row-name{font-size:13px;font-weight:500;display:flex;align-items:center;gap:6px;}'
|
|
37
|
+
+ '.orf-star{color:#bc8cff;font-size:11px;}'
|
|
38
|
+
+ '.orf-row-id{font-family:"JetBrains Mono",monospace;font-size:11px;color:#8b949e;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}'
|
|
39
|
+
+ '.orf-badges{display:flex;gap:6px;flex:none;}'
|
|
40
|
+
+ '.orf-badge{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500;border:1px solid transparent;}'
|
|
41
|
+
+ '.orf-badge.ctx{background:rgba(88,166,255,.12);color:#58a6ff;border-color:rgba(88,166,255,.25);font-family:"JetBrains Mono",monospace;}'
|
|
42
|
+
+ '.orf-badge.img{background:rgba(188,140,255,.12);color:#bc8cff;border-color:rgba(188,140,255,.25);}'
|
|
43
|
+
+ '.orf-badge.think{background:rgba(210,153,34,.12);color:#d29922;border-color:rgba(210,153,34,.25);}'
|
|
44
|
+
+ '.orf-badge.use{background:rgba(63,185,80,.15);color:#3fb950;border-color:rgba(63,185,80,.35);}'
|
|
45
|
+
+ '.orf-note{flex:none;display:flex;align-items:center;gap:8px;font-size:11px;color:#484f58;}'
|
|
46
|
+
+ '.orf-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1c2128;border:1px solid #2ea043;color:#3fb950;padding:9px 18px;border-radius:10px;font-size:12px;z-index:9999;box-shadow:0 8px 30px rgba(0,0,0,.5);}'
|
|
47
|
+
+ '.orf-toast.err{border-color:#f85149;color:#f85149;}'
|
|
48
|
+
+ '.orf-empty{padding:40px;text-align:center;color:#484f58;font-size:13px;}'
|
|
49
|
+
+ '.orb-spin{animation:orfSpin 1s linear infinite;}@keyframes orfSpin{to{transform:rotate(360deg)}}'
|
|
50
|
+
|
|
51
|
+
function injectStyles(css) {
|
|
52
|
+
if (typeof document === 'undefined') return
|
|
53
|
+
var el = document.createElement('style')
|
|
54
|
+
el.setAttribute('data-orf', 'style')
|
|
55
|
+
el.textContent = css
|
|
56
|
+
document.head.appendChild(el)
|
|
57
|
+
return function () { el.remove() }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function api(method, args) {
|
|
61
|
+
return fetch('/__orfree/api', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ method: method, args: args || {} }),
|
|
65
|
+
}).then(function (r) {
|
|
66
|
+
return r.json().then(function (j) {
|
|
67
|
+
if (!r.ok || j.ok === false) throw new Error(j && j.error ? j.error : 'HTTP ' + r.status)
|
|
68
|
+
return j
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fmtCtx(n) {
|
|
74
|
+
if (!n) return '—'
|
|
75
|
+
if (n >= 1000000) return (Math.round(n / 100000) / 10) + 'M'
|
|
76
|
+
if (n >= 1000) return Math.round(n / 1000) + 'K'
|
|
77
|
+
return String(n)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function IcRefresh(p) {
|
|
81
|
+
return React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'currentColor', className: p && p.spin ? 'orb-spin' : '' },
|
|
82
|
+
React.createElement('path', { d: 'M8 3a5 5 0 1 0 4.9 6h1.55A6.5 6.5 0 1 1 13.7 3.7L16 6h-5V1l1.8 1.8A5 5 0 0 0 8 3z' }))
|
|
83
|
+
}
|
|
84
|
+
function IcDownload() {
|
|
85
|
+
return React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'currentColor' },
|
|
86
|
+
React.createElement('path', { d: 'M8 1v8.6L5 6.5 3.9 7.6 8 11.7l4.1-4.1L11 6.5 8 9.6V1H8zM2 13h12v1.5H2V13z' }))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function apply(ctx) {
|
|
90
|
+
const slots = ctx.get('slots')
|
|
91
|
+
if (slots === undefined) return
|
|
92
|
+
ctx.effect(() => injectStyles(CSS))
|
|
93
|
+
slots.inject('conversation.view', () => slots.register(
|
|
94
|
+
{ name: 'conversation.view', id: 'dsh-openrouter-free', label: '免费模型', order: 9 },
|
|
95
|
+
(props) => {
|
|
96
|
+
function Panel() {
|
|
97
|
+
const [models, setModels] = React.useState(null)
|
|
98
|
+
const [cur, setCur] = React.useState(null)
|
|
99
|
+
const [loading, setLoading] = React.useState(true)
|
|
100
|
+
const [error, setError] = React.useState('')
|
|
101
|
+
const [toast, setToast] = React.useState('')
|
|
102
|
+
const [query, setQuery] = React.useState('')
|
|
103
|
+
const [refreshing, setRefreshing] = React.useState(false)
|
|
104
|
+
const [switching, setSwitching] = React.useState('')
|
|
105
|
+
|
|
106
|
+
function showToast(msg, isErr) { setToast({ msg: msg, err: !!isErr }); setTimeout(() => setToast(''), 2600) }
|
|
107
|
+
function loadCurrent() { api('current').then(j => setCur(j.default || {})).catch(() => {}) }
|
|
108
|
+
function loadList(refresh) {
|
|
109
|
+
setRefreshing(true); if (refresh) setError('')
|
|
110
|
+
api('free.list', { refresh: !!refresh })
|
|
111
|
+
.then(j => setModels(j.models || []))
|
|
112
|
+
.catch(e => setError(String(e.message || e)))
|
|
113
|
+
.then(() => { setRefreshing(false); setLoading(false); })
|
|
114
|
+
}
|
|
115
|
+
React.useEffect(() => { loadCurrent(); loadList(false) }, [])
|
|
116
|
+
|
|
117
|
+
function switchTo(m) {
|
|
118
|
+
setSwitching(m.id); setError('')
|
|
119
|
+
api('model.use', { id: m.id })
|
|
120
|
+
.then(j => { showToast('已切换 → ' + m.id + '(下一次请求生效)'); return loadCurrent() })
|
|
121
|
+
.catch(e => { setError(String(e.message || e)); showToast(String(e.message || e), true) })
|
|
122
|
+
.then(() => setSwitching(''))
|
|
123
|
+
}
|
|
124
|
+
function syncAll() {
|
|
125
|
+
setSwitching('__all__')
|
|
126
|
+
api('sync.all')
|
|
127
|
+
.then(j => { showToast('已把 ' + j.installedCount + ' 个免费模型装入选择器'); return loadCurrent() })
|
|
128
|
+
.catch(e => { showToast(String(e.message || e), true) })
|
|
129
|
+
.then(() => setSwitching(''))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const list = (models || []).filter(function (m) {
|
|
133
|
+
if (!query) return true
|
|
134
|
+
return (m.id + ' ' + m.name).toLowerCase().indexOf(query.toLowerCase()) >= 0
|
|
135
|
+
})
|
|
136
|
+
list.sort(function (a, b) {
|
|
137
|
+
var score = function (m) { return (m.contextWindow || 0) / 1048576 * 100 + (m.reasoning ? 50 : 0) + (m.input && m.input.indexOf('image') >= 0 ? 10 : 0) + (m.stealth ? 10 : 0) }
|
|
138
|
+
return score(b) - score(a)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const activeId = cur && cur.model ? cur.model : ''
|
|
142
|
+
|
|
143
|
+
const header = React.createElement('div', { className: 'orf-top' },
|
|
144
|
+
React.createElement('div', { className: 'orf-brand' },
|
|
145
|
+
React.createElement('span', { className: 'orf-dot' }),
|
|
146
|
+
React.createElement('span', { className: 'orf-title' }, 'OpenRouter 免费模型'),
|
|
147
|
+
React.createElement('span', { className: 'orf-sub' }, models ? (models.length + ' 个可用 · $0 输入/$0 输出') : '')),
|
|
148
|
+
React.createElement('div', { className: 'orf-actions' },
|
|
149
|
+
React.createElement('button', { className: 'orf-btn', onClick: () => loadList(true), disabled: refreshing, title: '重新拉取 OpenRouter 免费清单' },
|
|
150
|
+
React.createElement(IcRefresh, { spin: refreshing }), refreshing ? '刷新中…' : '刷新清单'),
|
|
151
|
+
React.createElement('button', { className: 'orf-btn primary', onClick: syncAll, disabled: switching === '__all__' || !models },
|
|
152
|
+
React.createElement(IcDownload), '全部装进选择器')))
|
|
153
|
+
|
|
154
|
+
const curBar = React.createElement('div', { className: 'orf-cur' },
|
|
155
|
+
React.createElement('span', { className: 'orf-cur-label' }, '当前对话模型'),
|
|
156
|
+
React.createElement('span', { className: 'orf-cur-model' }, (cur && cur.model) || '(未设置)'),
|
|
157
|
+
cur && cur.reasoningEffort ? React.createElement('span', { className: 'orf-cur-effort' }, '推理档 ' + cur.reasoningEffort) : null,
|
|
158
|
+
React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, color: '#484f58' } }, 'provider: ' + ((cur && cur.provider) || 'openrouter')))
|
|
159
|
+
|
|
160
|
+
const toolbar = React.createElement('div', { className: 'orf-toolbar' },
|
|
161
|
+
React.createElement('input', { className: 'orf-search', placeholder: '搜索模型名 / 厂商…', value: query, onChange: (e) => setQuery(e.target.value) }),
|
|
162
|
+
React.createElement('span', { className: 'orf-meta' }, '显示 ' + list.length + ' / ' + (models ? models.length : 0)))
|
|
163
|
+
|
|
164
|
+
function makeRow(m) {
|
|
165
|
+
const isActive = activeId === m.id
|
|
166
|
+
const s = (m.contextWindow || 0) / 1048576 * 100 + (m.reasoning ? 50 : 0) + (m.input && m.input.indexOf('image') >= 0 ? 10 : 0) + (m.stealth ? 10 : 0)
|
|
167
|
+
const stars = Math.max(1, Math.min(5, Math.round(s / 20)))
|
|
168
|
+
return React.createElement('div', {
|
|
169
|
+
key: m.id,
|
|
170
|
+
className: 'orf-row' + (isActive ? ' active' : '') + (switching === m.id ? ' switching' : ''),
|
|
171
|
+
onClick: () => { if (!isActive) switchTo(m) },
|
|
172
|
+
title: isActive ? '当前对话模型' : '点击切换到 ' + m.id,
|
|
173
|
+
},
|
|
174
|
+
React.createElement('div', { className: 'orf-row-main' },
|
|
175
|
+
React.createElement('div', { className: 'orf-row-name' },
|
|
176
|
+
m.name,
|
|
177
|
+
React.createElement('span', { className: 'orf-star', title: '综合评分 ' + stars + '★ (上下文+推理+图片+热门)' }, Array(stars + 1).join('★')),
|
|
178
|
+
React.createElement('div', { className: 'orf-row-id' }, m.id)
|
|
179
|
+
),
|
|
180
|
+
React.createElement('div', { className: 'orf-badges' },
|
|
181
|
+
isActive ? React.createElement('span', { className: 'orf-badge use' }, '✓ 使用中') : null,
|
|
182
|
+
m.reasoning ? React.createElement('span', { className: 'orf-badge think' }, '🧠 推理') : null,
|
|
183
|
+
m.input && m.input.indexOf('image') >= 0 ? React.createElement('span', { className: 'orf-badge img' }, '🖼 图片') : null,
|
|
184
|
+
React.createElement('span', { className: 'orf-badge ctx' }, fmtCtx(m.contextWindow))
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const rows = list.map(makeRow)
|
|
191
|
+
|
|
192
|
+
const body = loading
|
|
193
|
+
? React.createElement('div', { className: 'orf-empty' }, '正在拉取 OpenRouter 免费清单…')
|
|
194
|
+
: error
|
|
195
|
+
? React.createElement('div', { className: 'orf-empty', style: { color: '#f85149' } }, '出错了:' + error)
|
|
196
|
+
: list.length ? React.createElement('div', { className: 'orf-list' }, rows) : React.createElement('div', { className: 'orf-empty' }, '没有匹配的免费模型')
|
|
197
|
+
|
|
198
|
+
const toastEl = toast ? React.createElement('div', { className: 'orf-toast' + (toast.err ? ' err' : '') }, toast.msg) : null
|
|
199
|
+
|
|
200
|
+
return React.createElement('div', { className: 'orf-wrap' }, header, curBar, toolbar, body,
|
|
201
|
+
React.createElement('div', { className: 'orf-note' }, '💡 点击任意行立即切换为对话模型(免重启);“全部装进选择器”把所有免费模型写入 openrouter 路由。免费模型有平台速率限制。'),
|
|
202
|
+
toastEl
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
return React.createElement(Panel, props)
|
|
206
|
+
})
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
exports.apply = apply
|
|
211
|
+
return module.exports
|
|
212
|
+
},
|
|
213
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-openrouter-free — host half
|
|
3
|
+
*
|
|
4
|
+
* 自动识别 OpenRouter 免费模型 + 一键切换:
|
|
5
|
+
* free.list({refresh}) → 拉取 openrouter.ai/api/v1/models(公开,免密钥),
|
|
6
|
+
* 过滤 pricing.prompt==='0' && pricing.completion==='0',
|
|
7
|
+
* 内存缓存 10 分钟。
|
|
8
|
+
* current() → 读 agent-default-model 设置分节(当前对话模型)。
|
|
9
|
+
* use({id}) → 把该免费模型 upsert 进 llm-pi-ai.providers.openrouter.models
|
|
10
|
+
* (保留所有非免费手工条目,如 ox-alpha 的精调条目),
|
|
11
|
+
* 再把 agent-default-model 指向它。settings 写入后
|
|
12
|
+
* 下一次请求即生效(llm-pi-ai 每操作重读 profile,无需重启)。
|
|
13
|
+
* syncAll() → 全部免费模型一次装入选择器。
|
|
14
|
+
* removeFree({id}) → 从路由移除某个免费模型(当前选中者拒绝移除)。
|
|
15
|
+
*
|
|
16
|
+
* webServer route: /__orfree/api(client fetch)
|
|
17
|
+
* 设计依据:packages/llm/llm-pi-ai/README.zh.md —— models 列表=替换该路由 catalog;
|
|
18
|
+
* settings 分节更新即时生效;agent-default-model 是 GUI 模型选择器写的同一分节。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const name = 'dsh-openrouter-free'
|
|
22
|
+
|
|
23
|
+
// settings 用可选获取:服务缺席时 API 返回可读错误,不阻塞 bundle 树激活
|
|
24
|
+
export const inject = ['webServer']
|
|
25
|
+
|
|
26
|
+
const MODELS_URL = 'https://openrouter.ai/api/v1/models'
|
|
27
|
+
const ROUTE_NS = 'llm-pi-ai'
|
|
28
|
+
const DEFAULT_NS = 'agent-default-model'
|
|
29
|
+
const PROVIDER_KEY = 'openrouter'
|
|
30
|
+
const CACHE_TTL_MS = 10 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
// ---------- OpenRouter 模型 → DSH llm-pi-ai 条目映射 ----------
|
|
33
|
+
|
|
34
|
+
function mapModel(m) {
|
|
35
|
+
if (!m || typeof m.id !== 'string') return null
|
|
36
|
+
const pricing = m.pricing || {}
|
|
37
|
+
// 免费判定:输入与输出单价均为 $0(OpenRouter 价格字段是字符串)
|
|
38
|
+
if (!(String(pricing.prompt) === '0' && String(pricing.completion) === '0')) return null
|
|
39
|
+
|
|
40
|
+
const mods = (m.architecture && m.architecture.input_modalities) || []
|
|
41
|
+
const input = Array.isArray(mods) && mods.indexOf('image') >= 0 ? ['text', 'image'] : ['text']
|
|
42
|
+
const sp = Array.isArray(m.supported_parameters) ? m.supported_parameters : []
|
|
43
|
+
const reasoning = sp.indexOf('reasoning') >= 0
|
|
44
|
+
const top = m.top_provider || {}
|
|
45
|
+
const maxTokens = top.max_completion_tokens || undefined
|
|
46
|
+
const contextWindow = m.context_length || undefined
|
|
47
|
+
const displayName = m.name || m.id
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
id: m.id,
|
|
51
|
+
name: displayName,
|
|
52
|
+
contextWindow: contextWindow,
|
|
53
|
+
maxTokens: maxTokens,
|
|
54
|
+
input: input,
|
|
55
|
+
reasoning: reasoning,
|
|
56
|
+
stealth: m.id.indexOf('stealth/') === 0,
|
|
57
|
+
// 写入 llm-pi-ai providers.openrouter.models 的条目(仅 schema 认识的字段)。
|
|
58
|
+
// reasoningEfforts 不声明 off:避免空值歧义,默认档位由 agent-default-model 给 high。
|
|
59
|
+
entry: Object.assign(
|
|
60
|
+
{ id: m.id, name: displayName },
|
|
61
|
+
contextWindow ? { contextWindow } : {},
|
|
62
|
+
maxTokens ? { maxTokens } : {},
|
|
63
|
+
{ input },
|
|
64
|
+
reasoning ? { reasoningEfforts: { low: 'low', medium: 'medium', high: 'high' } } : {}
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fmtErr(error) {
|
|
70
|
+
return String((error && error.message) || error)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------- apply ----------
|
|
74
|
+
|
|
75
|
+
export function apply(ctx) {
|
|
76
|
+
const web = ctx.get('webServer')
|
|
77
|
+
if (!web) return
|
|
78
|
+
|
|
79
|
+
let cache = null
|
|
80
|
+
let fetchedAt = 0
|
|
81
|
+
|
|
82
|
+
async function fetchFree(force) {
|
|
83
|
+
if (!force && cache && Date.now() - fetchedAt < CACHE_TTL_MS) {
|
|
84
|
+
return { rows: cache, cached: true, fetchedAt }
|
|
85
|
+
}
|
|
86
|
+
const res = await fetch(MODELS_URL, {
|
|
87
|
+
headers: { accept: 'application/json' },
|
|
88
|
+
signal: AbortSignal.timeout(20000),
|
|
89
|
+
})
|
|
90
|
+
if (!res.ok) throw new Error('OpenRouter HTTP ' + res.status)
|
|
91
|
+
const j = await res.json()
|
|
92
|
+
const all = (j && j.data) || []
|
|
93
|
+
const rows = []
|
|
94
|
+
for (const m of all) {
|
|
95
|
+
const row = mapModel(m)
|
|
96
|
+
if (row) rows.push(row)
|
|
97
|
+
}
|
|
98
|
+
// stealth 置顶,其余按上下文窗口降序
|
|
99
|
+
rows.sort((a, b) => {
|
|
100
|
+
if (a.stealth !== b.stealth) return a.stealth ? -1 : 1
|
|
101
|
+
return (b.contextWindow || 0) - (a.contextWindow || 0)
|
|
102
|
+
})
|
|
103
|
+
cache = rows
|
|
104
|
+
fetchedAt = Date.now()
|
|
105
|
+
return { rows, cached: false, fetchedAt }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function settingsSvc() {
|
|
109
|
+
const s = ctx.get('settings')
|
|
110
|
+
if (!s) throw new Error('settings 服务不可用(本插件需要 settings seam)')
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readRouteModels() {
|
|
115
|
+
try {
|
|
116
|
+
const section = ctx.get('settings') ? ctx.get('settings').get(ROUTE_NS) : null
|
|
117
|
+
const prov = section && section.providers && section.providers[PROVIDER_KEY]
|
|
118
|
+
return Array.isArray(prov && prov.models) ? prov.models.slice() : []
|
|
119
|
+
} catch {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readDefault() {
|
|
125
|
+
try {
|
|
126
|
+
const d = ctx.get('settings') ? ctx.get('settings').get(DEFAULT_NS) : null
|
|
127
|
+
return d && typeof d === 'object'
|
|
128
|
+
? { provider: d.provider || '', model: d.model || '', reasoningEffort: d.reasoningEffort }
|
|
129
|
+
: {}
|
|
130
|
+
} catch {
|
|
131
|
+
return {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const api = {
|
|
136
|
+
async 'free.list'(args) {
|
|
137
|
+
const { rows, cached, fetchedAt } = await fetchFree(Boolean(args && args.refresh))
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
count: rows.length,
|
|
141
|
+
cached,
|
|
142
|
+
fetchedAt,
|
|
143
|
+
models: rows.map((r) => ({
|
|
144
|
+
id: r.id,
|
|
145
|
+
name: r.name,
|
|
146
|
+
contextWindow: r.contextWindow,
|
|
147
|
+
maxTokens: r.maxTokens,
|
|
148
|
+
input: r.input,
|
|
149
|
+
reasoning: r.reasoning,
|
|
150
|
+
stealth: r.stealth,
|
|
151
|
+
})),
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
current() {
|
|
156
|
+
return { ok: true, default: readDefault(), installedIds: readRouteModels().map((e) => e && e.id).filter(Boolean) }
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async 'model.use'(args) {
|
|
160
|
+
const id = String((args && args.id) || '')
|
|
161
|
+
if (!id) throw new Error('missing id')
|
|
162
|
+
const { rows } = await fetchFree(false)
|
|
163
|
+
const row = rows.find((r) => r.id === id)
|
|
164
|
+
if (!row) throw new Error('不是免费模型或清单里没有它:' + id)
|
|
165
|
+
|
|
166
|
+
const s = settingsSvc()
|
|
167
|
+
const models = readRouteModels()
|
|
168
|
+
const next = models.filter((e) => !(e && e.id === id))
|
|
169
|
+
next.push(row.entry)
|
|
170
|
+
await s.update(ROUTE_NS, { providers: { [PROVIDER_KEY]: { models: next } } })
|
|
171
|
+
|
|
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)
|
|
177
|
+
|
|
178
|
+
return { ok: true, applied: doc, installedCount: next.length, note: '下一次请求即生效,无需重启' }
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async 'sync.all'() {
|
|
182
|
+
const { rows } = await fetchFree(false)
|
|
183
|
+
if (!rows.length) throw new Error('免费清单为空')
|
|
184
|
+
const s = settingsSvc()
|
|
185
|
+
const models = readRouteModels()
|
|
186
|
+
const freeIds = new Set(rows.map((r) => r.id))
|
|
187
|
+
// 非免费的手工条目原样保留(如 ox-alpha 精调条目),免费条目整体刷新
|
|
188
|
+
const keptManual = models.filter((e) => !(e && typeof e.id === 'string' && freeIds.has(e.id)))
|
|
189
|
+
const entries = rows.map((r) => r.entry)
|
|
190
|
+
await s.update(ROUTE_NS, { providers: { [PROVIDER_KEY]: { models: keptManual.concat(entries) } } })
|
|
191
|
+
return { ok: true, installedCount: entries.length, keptManual: keptManual.length, note: '已全部装入模型选择器' }
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async 'free.remove'(args) {
|
|
195
|
+
const id = String((args && args.id) || '')
|
|
196
|
+
if (!id) throw new Error('missing id')
|
|
197
|
+
const def = readDefault()
|
|
198
|
+
if (def.model === id) throw new Error('该模型是当前对话模型,请先切换到别的模型再移除')
|
|
199
|
+
const s = settingsSvc()
|
|
200
|
+
const models = readRouteModels()
|
|
201
|
+
const next = models.filter((e) => !(e && e.id === id))
|
|
202
|
+
if (next.length === models.length) throw new Error('路由中没有这个模型:' + id)
|
|
203
|
+
await s.update(ROUTE_NS, { providers: { [PROVIDER_KEY]: { models: next } } })
|
|
204
|
+
return { ok: true, installedCount: next.length }
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function handle(req, res) {
|
|
209
|
+
let body = ''
|
|
210
|
+
req.on('data', (chunk) => { body += chunk })
|
|
211
|
+
req.on('end', () => {
|
|
212
|
+
let payload = {}
|
|
213
|
+
try { payload = JSON.parse(body || '{}') } catch { /* ignore */ }
|
|
214
|
+
const method = String((payload && payload.method) || '')
|
|
215
|
+
const fn = api[method]
|
|
216
|
+
if (!fn) {
|
|
217
|
+
res.writeHead(404, { 'content-type': 'application/json' })
|
|
218
|
+
res.end(JSON.stringify({ ok: false, error: 'unknown method ' + method }))
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
Promise.resolve(fn((payload && payload.args) || {})).then((result) => {
|
|
222
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache' })
|
|
223
|
+
res.end(JSON.stringify(result))
|
|
224
|
+
}).catch((error) => {
|
|
225
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
226
|
+
res.end(JSON.stringify({ ok: false, error: fmtErr(error) }))
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
ctx.effect(() => web.register({ kind: 'prefix', path: '/__orfree', handler: handle }))
|
|
232
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-openrouter-free",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenRouter 免费模型面板:自动拉取实时免费模型清单(综合能力 1-5★ 分级、最优优先排序),一键安装进 openrouter 路由并设为当前对话模型(推理档默认 max),免手动配置。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"cordis",
|
|
9
|
+
"plugin",
|
|
10
|
+
"openrouter",
|
|
11
|
+
"free-models"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./lib/index.js",
|
|
17
|
+
"./client": "./lib/client.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
},
|
|
24
|
+
"client": {
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
+
"@deepseek-ai/dsh-client-connection",
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
29
|
+
"@deepseek-ai/dsh-client-locale"
|
|
30
|
+
],
|
|
31
|
+
"platform": "web"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"lib/index.js",
|
|
36
|
+
"lib/client.js",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "^18.2.0"
|
|
43
|
+
},
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/jiang12345-code/dsh-openrouter-free.git"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/jiang12345-code/dsh-openrouter-free#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/jiang12345-code/dsh-openrouter-free/issues"
|
|
52
|
+
}
|
|
53
|
+
}
|