@goodandready/dsh-clinebot 0.5.15 → 0.5.17
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/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +76 -15
- package/lib/index.js +26 -16
- package/lib/provider-sync.js +25 -9
- package/lib/proxy-server.js +145 -0
- package/lib/routes/proxy.js +93 -107
- package/lib/slash-command.js +4 -4
- package/lib/stats-storage.js +16 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.17] - 2026-10-05
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Cosmokit Volatile Getters Unwrapping in Live Config (Issues #194, #200)**: Resolved issue where `live()` returned raw Cosmokit `Volatile` `{ get }` object boxes for volatile schema fields, causing downstream boolean checks (such as `!pub.enabled` in `syncProviderState`) to always evaluate as truthy objects regardless of actual boolean value. Fully unwrapped `live()` return values via `plainConfig(Config(plainConfig(...)))` and guaranteed robust fallback defaults when `apply()` receives null/undefined config.
|
|
12
|
+
- **Proxy Body Read Timeout & Payload Size Bounds (Issues #198, #202)**: Switched non-streaming upstream body read timeouts from `streamIdleTimeoutMs` (30s) to `bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)`. Added strict guard against unbounded payload responses (`MAX_BODY_BYTES = 10 * 1024 * 1024`) returning 502 Bad Gateway if upstream response exceeds 10MB.
|
|
13
|
+
- **Dead Configuration Keys & Timeout Cleanup (Issue #197)**: Removed non-existent configuration keys (`streamTotalTimeoutMs`, `connectTimeoutMs`, `idleTimeoutMs`) from `lib/routes/proxy.js` and eliminated dead timer logic.
|
|
14
|
+
- **DSH Non-Existent Service API Calls Removal (Issue #199)**: Removed dead `settings?.get` and `settingsApi.watch` calls on DSH services that lack those methods.
|
|
15
|
+
- **Corrupted Stats Backup Pruning (Issue #201)**: Added automatic rotation and pruning for `.corrupt.*` backup files in `lib/stats-storage.js`, retaining at most the 3 latest diagnostic copies to prevent unbounded disk usage.
|
|
16
|
+
- **Version Literals & Slash Command Cleanup (Issue #202)**: Synchronized hardcoded fallback version literals to `'0.5.17'`, cleaned up unused `invocation` argument in `slash-command.js`, and updated fallback port detection (`ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080`).
|
|
17
|
+
|
|
18
|
+
## [0.5.16] - 2026-10-04
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Dedicated Loopback Proxy Server & DSH Desktop 2.x Compatibility (Issues GH #12, #195)**: Resolved issue where `DesktopWebServer` on DSH Desktop 2.x in advanced shell mode rejected all internal loopback requests from the Node.js host process with HTTP 403 `forbidden` (lacking `x-dsh-desktop-renderer`). Decoupled proxy execution by spinning up an isolated dedicated HTTP loopback server listening on `127.0.0.1` (ephemeral port), completely bypassing host route fencing and restoring transparent proxy routing, multi-account failover, and telemetry on Desktop installations.
|
|
22
|
+
- **Desktop Fence Self-Detection & Direct Upstream Fallback (Issues GH #12, #195)**: Added `detectHostWebServerFence` probe that detects `DesktopWebServer` 403 `forbidden` signatures on host ports; automatically falls back to direct upstream endpoint (`baseUrl`) if the dedicated loopback server is unavailable or fails to bind, guaranteeing requests never stall with `AUTH 403`.
|
|
23
|
+
- **Proxy Mode UI Toggle in Diagnostics Section (Issues GH #12, #195)**: Added an explicit user-facing toggle in the Diagnostics & Auto-Registration settings card (`src/client/components/diag-section.js`) allowing users to easily switch between Transparent Loopback Proxy Mode (failover & telemetry) and Direct Mode (direct upstream connection) directly from the Web & Desktop UI.
|
|
24
|
+
|
|
8
25
|
## [0.5.15] - 2026-10-02
|
|
9
26
|
|
|
10
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -229,6 +229,8 @@ All endpoints are registered under `/dsh-clinebot/*` and protected against untru
|
|
|
229
229
|
* `POST /dsh-clinebot/v1/chat/completions` — Transparent local proxy supporting streaming SSE and non-streaming responses, Bearer token authentication, sticky session affinity with least-used quota allocation, body read watchdog (`streamIdleTimeoutMs`), and automatic 429 failover.
|
|
230
230
|
* `GET /dsh-clinebot/v1/models` — OpenAI-compatible catalog listing all active plan and custom models.
|
|
231
231
|
|
|
232
|
+
> **DSH Desktop 2.x & Headless Environments**: The loopback proxy binds an isolated loopback HTTP server on `127.0.0.1` (ephemeral port), completely bypassing `DesktopWebServer` renderer fences (`x-dsh-desktop-renderer`). Automated fence detection (`detectHostWebServerFence`) falls back to direct upstream connection if the loopback server is restricted, and proxy mode can be directly toggled via the Diagnostics settings card.
|
|
233
|
+
|
|
232
234
|
---
|
|
233
235
|
|
|
234
236
|
## Visual verification
|
package/README.ru.md
CHANGED
|
@@ -226,6 +226,8 @@ dsh-clinebot:
|
|
|
226
226
|
* `POST /dsh-clinebot/v1/chat/completions` — Прозрачный локальный прокси с поддержкой SSE и нестриминговых ответов, Bearer-аутентификацией, сессионной липкостью и least-used распределением квот, контролем дедлайнов (`streamIdleTimeoutMs`) и автоматическим 429 failover.
|
|
227
227
|
* `GET /dsh-clinebot/v1/models` — Каталог моделей в стандарте OpenAI API.
|
|
228
228
|
|
|
229
|
+
> **DSH Desktop 2.x и автономные окружения**: Прокси поднимает изолированный выделенный HTTP-сервер на `127.0.0.1` (эфемеридный порт), полностью обходя сетевые экраны `DesktopWebServer` (`x-dsh-desktop-renderer`). Встроенный детектор защитных экранов (`detectHostWebServerFence`) автоматически переключает провайдер на прямое соединение с upstream при ограничении loopback-порта, а режим прокси можно переключать напрямую в карточке «Диагностика» в UI.
|
|
230
|
+
|
|
229
231
|
---
|
|
230
232
|
|
|
231
233
|
## 🧪 Тестирование
|
package/README.zh.md
CHANGED
|
@@ -228,6 +228,8 @@ dsh-clinebot:
|
|
|
228
228
|
* `POST /dsh-clinebot/v1/chat/completions` — 本地透明代理,支持 SSE 流式与完整响应、会话粘性路由、最低用量负载均衡、响应体超时防护 (`streamIdleTimeoutMs`) 以及 429 自动故障转移。
|
|
229
229
|
* `GET /dsh-clinebot/v1/models` — 符合 OpenAI API 规范的模型列表。
|
|
230
230
|
|
|
231
|
+
> **DSH Desktop 2.x 与无界面环境**:环回代理在 `127.0.0.1` 绑定独立的专用 HTTP 服务(临时端口),彻底绕过桌面端路由防护 (`DesktopWebServer` 的 `x-dsh-desktop-renderer` 限制)。内置防护检测机制 (`detectHostWebServerFence`) 在环回端口受限时自动回退为直连模式,用户亦可在设置页的“诊断”模块中直接切换代理模式。
|
|
232
|
+
|
|
231
233
|
---
|
|
232
234
|
|
|
233
235
|
## 🧪 测试
|
package/lib/client.js
CHANGED
|
@@ -178,6 +178,10 @@ const en = {
|
|
|
178
178
|
'diag.unregister_btn': 'Unregister from DSH',
|
|
179
179
|
'diag.unregistering': 'Removing…',
|
|
180
180
|
'diag.unregistered_msg': 'Provider removed from DSH Models',
|
|
181
|
+
'diag.proxy_title': 'Transparent Loopback Proxy',
|
|
182
|
+
'diag.proxy_desc': 'Enables multi-account failover, sticky sessions, and telemetry via local loopback proxy. Disable to connect directly to upstream endpoint.',
|
|
183
|
+
'diag.proxy_enabled': 'Proxy Mode Enabled (Failover & Telemetry Active)',
|
|
184
|
+
'diag.proxy_disabled': 'Direct Mode (Direct Upstream, No Proxy)',
|
|
181
185
|
'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
|
|
182
186
|
'update.btn': 'Update Now',
|
|
183
187
|
'update.updating': 'Updating…',
|
|
@@ -327,6 +331,10 @@ const en = {
|
|
|
327
331
|
'diag.unregister_btn': '从 DSH 注销该服务',
|
|
328
332
|
'diag.unregistering': '正在注销…',
|
|
329
333
|
'diag.unregistered_msg': '已从 DSH 模型注册表中移除',
|
|
334
|
+
'diag.proxy_title': '本地透明回环代理',
|
|
335
|
+
'diag.proxy_desc': '通过本地回环代理支持多账户故障转移、会话保持与实时统计。关闭后直连上游端点。',
|
|
336
|
+
'diag.proxy_enabled': '代理模式已开启(支持故障转移与遥测)',
|
|
337
|
+
'diag.proxy_disabled': '直连模式(直接连接上游,无本地代理)',
|
|
330
338
|
'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
|
|
331
339
|
'update.btn': '立即更新',
|
|
332
340
|
'update.updating': '正在更新…',
|
|
@@ -1835,12 +1843,72 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
1835
1843
|
}
|
|
1836
1844
|
|
|
1837
1845
|
// --- src/client/components/diag-section.js ---
|
|
1838
|
-
function DiagSection({
|
|
1846
|
+
function DiagSection({
|
|
1847
|
+
keyPresent,
|
|
1848
|
+
isRegistered,
|
|
1849
|
+
busy,
|
|
1850
|
+
smokeResult,
|
|
1851
|
+
handleSmoke,
|
|
1852
|
+
handleUnregister,
|
|
1853
|
+
handleRegister,
|
|
1854
|
+
proxyMode,
|
|
1855
|
+
onToggleProxy,
|
|
1856
|
+
t,
|
|
1857
|
+
}) {
|
|
1839
1858
|
return React.createElement(
|
|
1840
1859
|
'div',
|
|
1841
1860
|
{ className: 'cb-section-card' },
|
|
1842
1861
|
React.createElement('div', { className: 'cb-section-title' }, t('diag.title')),
|
|
1843
1862
|
React.createElement('div', { className: 'cb-section-desc' }, t('diag.desc')),
|
|
1863
|
+
React.createElement(
|
|
1864
|
+
'div',
|
|
1865
|
+
{
|
|
1866
|
+
style: {
|
|
1867
|
+
display: 'flex',
|
|
1868
|
+
alignItems: 'center',
|
|
1869
|
+
justifyContent: 'space-between',
|
|
1870
|
+
padding: '10px 14px',
|
|
1871
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1872
|
+
borderRadius: '8px',
|
|
1873
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1874
|
+
},
|
|
1875
|
+
},
|
|
1876
|
+
React.createElement(
|
|
1877
|
+
'div',
|
|
1878
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
|
|
1879
|
+
React.createElement(
|
|
1880
|
+
'span',
|
|
1881
|
+
{ style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
|
|
1882
|
+
t('diag.proxy_title')
|
|
1883
|
+
),
|
|
1884
|
+
React.createElement(
|
|
1885
|
+
'span',
|
|
1886
|
+
{ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
1887
|
+
t('diag.proxy_desc')
|
|
1888
|
+
)
|
|
1889
|
+
),
|
|
1890
|
+
React.createElement(
|
|
1891
|
+
'label',
|
|
1892
|
+
{ style: { display: 'inline-flex', alignItems: 'center', gap: '8px', cursor: 'pointer', whiteSpace: 'nowrap' } },
|
|
1893
|
+
React.createElement('input', {
|
|
1894
|
+
type: 'checkbox',
|
|
1895
|
+
checked: !!proxyMode,
|
|
1896
|
+
disabled: !!busy,
|
|
1897
|
+
onChange: (e) => onToggleProxy && onToggleProxy(e.target.checked),
|
|
1898
|
+
}),
|
|
1899
|
+
React.createElement(
|
|
1900
|
+
'span',
|
|
1901
|
+
{
|
|
1902
|
+
style: {
|
|
1903
|
+
fontSize: '13px',
|
|
1904
|
+
fontWeight: 500,
|
|
1905
|
+
color: proxyMode ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
1906
|
+
},
|
|
1907
|
+
},
|
|
1908
|
+
proxyMode ? t('diag.proxy_enabled') : t('diag.proxy_disabled')
|
|
1909
|
+
)
|
|
1910
|
+
)
|
|
1911
|
+
),
|
|
1844
1912
|
React.createElement(
|
|
1845
1913
|
'div',
|
|
1846
1914
|
{ className: 'cb-row' },
|
|
@@ -1901,8 +1969,7 @@ function SettingsPage(props) {
|
|
|
1901
1969
|
const [status, setStatus] = React.useState(null)
|
|
1902
1970
|
const [draft, setDraft] = React.useState(null)
|
|
1903
1971
|
const [busy, setBusy] = React.useState('')
|
|
1904
|
-
const [err, setErr] = React.useState('')
|
|
1905
|
-
const [msg, setMsg] = React.useState('')
|
|
1972
|
+
const [err, setErr] = React.useState(''), [msg, setMsg] = React.useState('')
|
|
1906
1973
|
const [smokeResult, setSmokeResult] = React.useState(null)
|
|
1907
1974
|
|
|
1908
1975
|
const [updateState, setUpdateState] = React.useState({
|
|
@@ -1911,8 +1978,7 @@ function SettingsPage(props) {
|
|
|
1911
1978
|
error: '', notice: '',
|
|
1912
1979
|
})
|
|
1913
1980
|
|
|
1914
|
-
const [apiKeyInput, setApiKeyInput] = React.useState('')
|
|
1915
|
-
const [showKey, setShowKey] = React.useState(false)
|
|
1981
|
+
const [apiKeyInput, setApiKeyInput] = React.useState(''), [showKey, setShowKey] = React.useState(false)
|
|
1916
1982
|
|
|
1917
1983
|
React.useEffect(() => { ensureCss() }, [])
|
|
1918
1984
|
|
|
@@ -1930,9 +1996,7 @@ function SettingsPage(props) {
|
|
|
1930
1996
|
setDraft(data.config || {})
|
|
1931
1997
|
}, [])
|
|
1932
1998
|
|
|
1933
|
-
React.useEffect(() => {
|
|
1934
|
-
load().catch((e) => setErr(String(e.message || e)))
|
|
1935
|
-
}, [load])
|
|
1999
|
+
React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
|
|
1936
2000
|
|
|
1937
2001
|
const checkUpdate = React.useCallback(async () => {
|
|
1938
2002
|
setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
|
|
@@ -2202,11 +2266,7 @@ function SettingsPage(props) {
|
|
|
2202
2266
|
|
|
2203
2267
|
const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
|
|
2204
2268
|
|
|
2205
|
-
React.useEffect(() => {
|
|
2206
|
-
return () => {
|
|
2207
|
-
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
2208
|
-
}
|
|
2209
|
-
}, [])
|
|
2269
|
+
React.useEffect(() => () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) }, [])
|
|
2210
2270
|
|
|
2211
2271
|
function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
|
|
2212
2272
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
@@ -2368,8 +2428,7 @@ function SettingsPage(props) {
|
|
|
2368
2428
|
onConfigUpdate: handleConfigPatch,
|
|
2369
2429
|
onUpdateReasoningEffort: (modelId, effort) => {
|
|
2370
2430
|
const next = { ...(draft?.modelReasoningDefaults || {}) }
|
|
2371
|
-
if (!effort) delete next[modelId]
|
|
2372
|
-
else next[modelId] = effort
|
|
2431
|
+
if (!effort) delete next[modelId]; else next[modelId] = effort
|
|
2373
2432
|
handleConfigPatch({ modelReasoningDefaults: next })
|
|
2374
2433
|
},
|
|
2375
2434
|
t,
|
|
@@ -2387,6 +2446,8 @@ function SettingsPage(props) {
|
|
|
2387
2446
|
handleSmoke,
|
|
2388
2447
|
handleUnregister,
|
|
2389
2448
|
handleRegister,
|
|
2449
|
+
proxyMode: draft?.proxyMode !== false,
|
|
2450
|
+
onToggleProxy: (val) => handleConfigPatch({ proxyMode: val }),
|
|
2390
2451
|
t,
|
|
2391
2452
|
})
|
|
2392
2453
|
)
|
package/lib/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { registerAccountsRoutes } from './routes/accounts.js'
|
|
|
23
23
|
import { registerModelsRoutes } from './routes/models.js'
|
|
24
24
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
25
25
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
26
|
+
import { startLoopbackProxyServer, stopLoopbackProxyServer } from './proxy-server.js'
|
|
26
27
|
import { registerSlashCommand } from './slash-command.js'
|
|
27
28
|
import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
|
|
28
29
|
|
|
@@ -36,7 +37,7 @@ export function configReader(config) {
|
|
|
36
37
|
export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
|
|
37
38
|
|
|
38
39
|
export function apply(ctx, config) {
|
|
39
|
-
let currentConfig = config
|
|
40
|
+
let currentConfig = config || {}
|
|
40
41
|
const overrides = {}
|
|
41
42
|
const getConfig = () => {
|
|
42
43
|
if (!currentConfig) return currentConfig
|
|
@@ -72,7 +73,7 @@ export function apply(ctx, config) {
|
|
|
72
73
|
}
|
|
73
74
|
return currentConfig
|
|
74
75
|
}
|
|
75
|
-
const live = () => (
|
|
76
|
+
const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
|
|
76
77
|
let settingsApi
|
|
77
78
|
|
|
78
79
|
const syncProviderState = async (cfg) => {
|
|
@@ -82,6 +83,11 @@ export function apply(ctx, config) {
|
|
|
82
83
|
await removePiAiProvider(ctx)
|
|
83
84
|
return
|
|
84
85
|
}
|
|
86
|
+
if (pub.proxyMode !== false && (ctx?.webServer || typeof ctx?.get === 'function')) {
|
|
87
|
+
await startLoopbackProxyServer(ctx, routeEnv)
|
|
88
|
+
} else {
|
|
89
|
+
await stopLoopbackProxyServer()
|
|
90
|
+
}
|
|
85
91
|
const activeKey = await resolveActiveAccountKey(ctx, cfg)
|
|
86
92
|
if (activeKey.value) {
|
|
87
93
|
await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
|
|
@@ -213,7 +219,7 @@ export function apply(ctx, config) {
|
|
|
213
219
|
applyConfigMutations(currentConfig, parsed)
|
|
214
220
|
syncStatsStorage(live())
|
|
215
221
|
await syncProviderState(live())
|
|
216
|
-
return parsed
|
|
222
|
+
return plainConfig(parsed)
|
|
217
223
|
},
|
|
218
224
|
update: async (patch) => {
|
|
219
225
|
const svc = ctx?.get?.('settings')
|
|
@@ -242,13 +248,9 @@ export function apply(ctx, config) {
|
|
|
242
248
|
applyConfigMutations(currentConfig, merged)
|
|
243
249
|
syncStatsStorage(live())
|
|
244
250
|
await syncProviderState(live())
|
|
245
|
-
return merged
|
|
246
|
-
},
|
|
247
|
-
watch: (cb) => {
|
|
248
|
-
const svc = ctx?.get?.('settings')
|
|
249
|
-
if (typeof svc?.watch === 'function') return svc.watch(cb)
|
|
250
|
-
return () => {}
|
|
251
|
+
return plainConfig(merged)
|
|
251
252
|
},
|
|
253
|
+
|
|
252
254
|
}
|
|
253
255
|
|
|
254
256
|
if (typeof ctx.effect === 'function') {
|
|
@@ -259,6 +261,21 @@ export function apply(ctx, config) {
|
|
|
259
261
|
}, 'dsh-clinebot: stats-storage')
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
const routeEnv = {
|
|
265
|
+
live,
|
|
266
|
+
getSettingsApi: () => settingsApi,
|
|
267
|
+
syncProviderState,
|
|
268
|
+
triggerAutoDiscover,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof ctx.effect === 'function') {
|
|
272
|
+
ctx.effect(() => {
|
|
273
|
+
return () => {
|
|
274
|
+
stopLoopbackProxyServer()
|
|
275
|
+
}
|
|
276
|
+
}, 'dsh-clinebot: loopback-proxy-server')
|
|
277
|
+
}
|
|
278
|
+
|
|
262
279
|
syncProviderState(live())
|
|
263
280
|
if (typeof ctx.effect === 'function') {
|
|
264
281
|
ctx.effect(() => {
|
|
@@ -281,13 +298,6 @@ export function apply(ctx, config) {
|
|
|
281
298
|
ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
|
|
282
299
|
}
|
|
283
300
|
|
|
284
|
-
const routeEnv = {
|
|
285
|
-
live,
|
|
286
|
-
getSettingsApi: () => settingsApi,
|
|
287
|
-
syncProviderState,
|
|
288
|
-
triggerAutoDiscover,
|
|
289
|
-
}
|
|
290
|
-
|
|
291
301
|
registerSettingsRoutes(ctx, routeEnv)
|
|
292
302
|
registerAccountsRoutes(ctx, routeEnv)
|
|
293
303
|
registerModelsRoutes(ctx, routeEnv)
|
package/lib/provider-sync.js
CHANGED
|
@@ -25,6 +25,7 @@ import { resolveKeyValue } from './credential-refs.js'
|
|
|
25
25
|
import { resolveAccountPool, getLastRotation } from './account-pool.js'
|
|
26
26
|
import { getLocalProxyToken, LOCAL_PROXY_KEY_ENV } from './proxy-token.js'
|
|
27
27
|
import { saveCredentialKey } from './credential-refs.js'
|
|
28
|
+
import { getProxyPort, detectHostWebServerFence } from './proxy-server.js'
|
|
28
29
|
|
|
29
30
|
export function resolvePathWithHome(p) {
|
|
30
31
|
let guard = 0
|
|
@@ -55,10 +56,7 @@ export async function checkRegisteredInPiAi(ctx) {
|
|
|
55
56
|
const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
|
|
56
57
|
if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
-
const piAi = settings.get(LLM_PI_AI_NS)
|
|
60
|
-
if (piAi?.providers?.[PROVIDER_ID]) return true
|
|
61
|
-
}
|
|
59
|
+
|
|
62
60
|
const llm = ctx?.get?.('llm')
|
|
63
61
|
if (typeof llm?.listProviders === 'function') {
|
|
64
62
|
const list = await llm.listProviders()
|
|
@@ -208,11 +206,29 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
|
208
206
|
|
|
209
207
|
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
210
208
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
209
|
+
let isProxy = pub.proxyMode !== false
|
|
210
|
+
let effectiveBaseUrl = pub.baseUrl
|
|
211
|
+
let proxyPort = getProxyPort()
|
|
212
|
+
|
|
213
|
+
if (isProxy) {
|
|
214
|
+
if (!proxyPort) {
|
|
215
|
+
proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
|
|
216
|
+
const isFenced = await detectHostWebServerFence(proxyPort)
|
|
217
|
+
if (isFenced) {
|
|
218
|
+
ctx?.logger?.warn?.(
|
|
219
|
+
`[dsh-clinebot] DesktopWebServer security fence detected on port ${proxyPort} and dedicated loopback proxy is not active. Falling back to direct upstream mode.`
|
|
220
|
+
)
|
|
221
|
+
isProxy = false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (isProxy && proxyPort) {
|
|
227
|
+
effectiveBaseUrl = `http://127.0.0.1:${proxyPort}/dsh-clinebot/v1`
|
|
228
|
+
} else {
|
|
229
|
+
isProxy = false
|
|
230
|
+
effectiveBaseUrl = pub.baseUrl
|
|
231
|
+
}
|
|
216
232
|
|
|
217
233
|
const localToken = isProxy ? getLocalProxyToken() : undefined
|
|
218
234
|
if (isProxy && localToken) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedicated Loopback Proxy Server (Issues GH #12, #195)
|
|
3
|
+
*
|
|
4
|
+
* Runs an isolated loopback HTTP server bound exclusively to 127.0.0.1 (ephemeral port).
|
|
5
|
+
* Completely bypasses DSH Desktop 2.x DesktopWebServer route fencing (`permits(req)`
|
|
6
|
+
* requiring `x-dsh-desktop-renderer` header), allowing Node.js host LLM requests to
|
|
7
|
+
* connect cleanly to loopback proxy endpoints without encountering HTTP 403 "forbidden".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'node:http'
|
|
11
|
+
import { createChatCompletionsHandler, createModelsHandler } from './routes/proxy.js'
|
|
12
|
+
|
|
13
|
+
let activeProxyServer = null
|
|
14
|
+
let activeProxyPort = null
|
|
15
|
+
let startingPromise = null
|
|
16
|
+
|
|
17
|
+
export function getProxyPort() {
|
|
18
|
+
return activeProxyPort
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resetProxyServerState() {
|
|
22
|
+
if (activeProxyServer) {
|
|
23
|
+
try {
|
|
24
|
+
activeProxyServer.close()
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
activeProxyServer = null
|
|
30
|
+
activeProxyPort = null
|
|
31
|
+
startingPromise = null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function detectHostWebServerFence(port) {
|
|
35
|
+
if (!port || typeof port !== 'number' || port <= 0) return false
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(`http://127.0.0.1:${port}/dsh-clinebot/v1/models`, {
|
|
38
|
+
method: 'GET',
|
|
39
|
+
signal: AbortSignal.timeout(1000),
|
|
40
|
+
})
|
|
41
|
+
if (res.status === 403) {
|
|
42
|
+
const text = await res.text().catch(() => '')
|
|
43
|
+
if (text.trim().toLowerCase() === 'forbidden') {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function startLoopbackProxyServer(ctx, routeEnv) {
|
|
54
|
+
if (activeProxyServer && activeProxyPort) {
|
|
55
|
+
return activeProxyPort
|
|
56
|
+
}
|
|
57
|
+
if (startingPromise) {
|
|
58
|
+
return startingPromise
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
startingPromise = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
|
|
64
|
+
const modelsHandler = createModelsHandler(ctx, routeEnv)
|
|
65
|
+
|
|
66
|
+
const server = http.createServer(async (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const rawUrl = req.url || '/'
|
|
69
|
+
const path = rawUrl.split('?')[0].replace(/\/+$/, '')
|
|
70
|
+
if (path === '/dsh-clinebot/v1/chat/completions') {
|
|
71
|
+
await chatHandler(req, res)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (path === '/dsh-clinebot/v1/models') {
|
|
75
|
+
await modelsHandler(req, res)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
res.statusCode = 404
|
|
79
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
80
|
+
res.end(JSON.stringify({ ok: false, error: 'Not found' }))
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!res.headersSent) {
|
|
83
|
+
res.statusCode = 500
|
|
84
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
85
|
+
res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const port = await new Promise((resolve, reject) => {
|
|
91
|
+
server.on('error', (err) => {
|
|
92
|
+
ctx?.logger?.warn?.(`[dsh-clinebot:proxy-server] Server error: ${err?.message || err}`)
|
|
93
|
+
reject(err)
|
|
94
|
+
})
|
|
95
|
+
server.listen(0, '127.0.0.1', () => {
|
|
96
|
+
const addr = server.address()
|
|
97
|
+
const assignedPort = typeof addr === 'object' && addr?.port ? addr.port : null
|
|
98
|
+
if (!assignedPort) {
|
|
99
|
+
reject(new Error('Failed to obtain assigned ephemeral port'))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (typeof server.unref === 'function') {
|
|
103
|
+
server.unref()
|
|
104
|
+
}
|
|
105
|
+
resolve(assignedPort)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
activeProxyServer = server
|
|
110
|
+
activeProxyPort = port
|
|
111
|
+
ctx?.logger?.info?.(`[dsh-clinebot:proxy-server] Dedicated loopback proxy listening on 127.0.0.1:${port}`)
|
|
112
|
+
return port
|
|
113
|
+
} catch (err) {
|
|
114
|
+
ctx?.logger?.warn?.(`[dsh-clinebot:proxy-server] Failed to bind dedicated loopback proxy server: ${err?.message || err}`)
|
|
115
|
+
activeProxyServer = null
|
|
116
|
+
activeProxyPort = null
|
|
117
|
+
return null
|
|
118
|
+
} finally {
|
|
119
|
+
startingPromise = null
|
|
120
|
+
}
|
|
121
|
+
})()
|
|
122
|
+
|
|
123
|
+
return startingPromise
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function stopLoopbackProxyServer() {
|
|
127
|
+
if (!activeProxyServer) return
|
|
128
|
+
const server = activeProxyServer
|
|
129
|
+
activeProxyServer = null
|
|
130
|
+
activeProxyPort = null
|
|
131
|
+
startingPromise = null
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
server.close(() => {
|
|
134
|
+
resolve()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function getOrStartProxyPort(ctx, routeEnv) {
|
|
140
|
+
if (activeProxyPort) return activeProxyPort
|
|
141
|
+
if (routeEnv) {
|
|
142
|
+
return await startLoopbackProxyServer(ctx, routeEnv)
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
package/lib/routes/proxy.js
CHANGED
|
@@ -24,9 +24,9 @@ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
|
|
|
24
24
|
import { getLocalProxyToken } from '../proxy-token.js'
|
|
25
25
|
|
|
26
26
|
const require = createRequire(import.meta.url)
|
|
27
|
-
let packageVersion = '0.5.
|
|
27
|
+
let packageVersion = '0.5.17'
|
|
28
28
|
try {
|
|
29
|
-
packageVersion = require('../../package.json').version || '0.5.
|
|
29
|
+
packageVersion = require('../../package.json').version || '0.5.17'
|
|
30
30
|
} catch (err) {
|
|
31
31
|
/* fallback to default version if manifest is unreadable */
|
|
32
32
|
}
|
|
@@ -35,13 +35,7 @@ export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
|
35
35
|
|
|
36
36
|
export function hasDisallowedBrowserOrigin(req) {
|
|
37
37
|
const secFetchSite = req.headers?.['sec-fetch-site']
|
|
38
|
-
|
|
39
|
-
return true
|
|
40
|
-
}
|
|
41
|
-
if (req.headers?.['origin']) {
|
|
42
|
-
return true
|
|
43
|
-
}
|
|
44
|
-
return false
|
|
38
|
+
return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
|
|
45
39
|
}
|
|
46
40
|
|
|
47
41
|
export function isAuthorizedProxyRequest(req, accountPool = []) {
|
|
@@ -51,26 +45,15 @@ export function isAuthorizedProxyRequest(req, accountPool = []) {
|
|
|
51
45
|
const token = match[1].trim()
|
|
52
46
|
if (!token) return false
|
|
53
47
|
const localToken = getLocalProxyToken()
|
|
54
|
-
if (localToken && token === localToken)
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
if (Array.isArray(accountPool) && accountPool.length > 0) {
|
|
58
|
-
if (accountPool.some((acc) => acc.value && acc.value === token)) {
|
|
59
|
-
return true
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return false
|
|
48
|
+
if (localToken && token === localToken) return true
|
|
49
|
+
return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
|
|
63
50
|
}
|
|
64
51
|
|
|
65
|
-
export function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
handler: async (req, res) => {
|
|
71
|
-
if (req.method !== 'POST') {
|
|
72
|
-
return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
73
|
-
}
|
|
52
|
+
export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
53
|
+
return async (req, res) => {
|
|
54
|
+
if (req.method !== 'POST') {
|
|
55
|
+
return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
56
|
+
}
|
|
74
57
|
|
|
75
58
|
if (!isLoopbackAddress(req.socket?.remoteAddress)) {
|
|
76
59
|
return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
|
|
@@ -102,20 +85,11 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
102
85
|
const cfg = plainConfig(live())
|
|
103
86
|
const pub = publicConfig(cfg)
|
|
104
87
|
|
|
105
|
-
|
|
88
|
+
const h = req.headers || {}
|
|
106
89
|
const sessionId = String(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
req.headers['session_id'] ||
|
|
111
|
-
req.headers['x-session-affinity'] ||
|
|
112
|
-
req.headers['x-client-request-id'] ||
|
|
113
|
-
body?.session_id ||
|
|
114
|
-
body?.conversation_id ||
|
|
115
|
-
body?.prompt_cache_key ||
|
|
116
|
-
body?.chat_id ||
|
|
117
|
-
body?.user ||
|
|
118
|
-
''
|
|
90
|
+
h['x-dsh-session-id'] || h['x-session-id'] || h['session-id'] || h['session_id'] ||
|
|
91
|
+
h['x-session-affinity'] || h['x-client-request-id'] ||
|
|
92
|
+
body?.session_id || body?.conversation_id || body?.prompt_cache_key || body?.chat_id || body?.user || ''
|
|
119
93
|
).trim()
|
|
120
94
|
|
|
121
95
|
if (sessionId) {
|
|
@@ -123,15 +97,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
123
97
|
if (!body.conversation_id) body.conversation_id = sessionId
|
|
124
98
|
}
|
|
125
99
|
|
|
126
|
-
// Ensure model is set, fallback to defaultModel (#140)
|
|
127
100
|
body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
|
|
128
|
-
|
|
129
|
-
// Inject model reasoning effort if model has default configured and payload omitted it
|
|
130
|
-
if (
|
|
131
|
-
pub.modelReasoningDefaults &&
|
|
132
|
-
pub.modelReasoningDefaults[body.model] &&
|
|
133
|
-
!body.reasoning_effort
|
|
134
|
-
) {
|
|
101
|
+
if (pub.modelReasoningDefaults?.[body.model] && !body.reasoning_effort) {
|
|
135
102
|
body.reasoning_effort = pub.modelReasoningDefaults[body.model]
|
|
136
103
|
}
|
|
137
104
|
|
|
@@ -194,8 +161,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
194
161
|
'User-Agent': USER_AGENT
|
|
195
162
|
}
|
|
196
163
|
|
|
197
|
-
const connectTimeoutMs = Math.max(50, Number(pub.
|
|
198
|
-
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs
|
|
164
|
+
const connectTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
|
|
165
|
+
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs) || 30000)
|
|
199
166
|
|
|
200
167
|
const connectAbortCtrl = new AbortController()
|
|
201
168
|
const bodyAbortCtrl = new AbortController()
|
|
@@ -316,16 +283,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
316
283
|
}
|
|
317
284
|
}
|
|
318
285
|
|
|
319
|
-
|
|
320
|
-
if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
|
|
321
|
-
const totalMs = Number(pub.streamTotalTimeoutMs)
|
|
322
|
-
totalTimer = setTimeout(() => {
|
|
323
|
-
streamError = new Error(`Stream total timeout after ${totalMs}ms`)
|
|
324
|
-
try {
|
|
325
|
-
reader.cancel('stream total timeout').catch(() => {})
|
|
326
|
-
} catch { /* reader already cancelled or closed */ }
|
|
327
|
-
}, totalMs)
|
|
328
|
-
}
|
|
286
|
+
|
|
329
287
|
|
|
330
288
|
try {
|
|
331
289
|
resetIdleTimer()
|
|
@@ -374,7 +332,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
374
332
|
ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
|
|
375
333
|
} finally {
|
|
376
334
|
clearIdleTimer()
|
|
377
|
-
|
|
335
|
+
|
|
378
336
|
clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
|
|
379
337
|
cleanupClientListeners()
|
|
380
338
|
try {
|
|
@@ -412,17 +370,26 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
412
370
|
return
|
|
413
371
|
}
|
|
414
372
|
|
|
415
|
-
// Non-streaming JSON response with deadline and fail-closed error handling (#185)
|
|
416
|
-
const
|
|
373
|
+
// Non-streaming JSON response with deadline and fail-closed error handling (#185, #198, #202)
|
|
374
|
+
const bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
|
|
417
375
|
let bodyTimer = null
|
|
418
376
|
const bodyTimeoutPromise = new Promise((_, reject) => {
|
|
419
377
|
bodyTimer = setTimeout(() => {
|
|
420
378
|
bodyTimedOut = true
|
|
421
|
-
bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${
|
|
422
|
-
reject(new Error(`Upstream body read timed out after ${
|
|
423
|
-
},
|
|
379
|
+
bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
|
|
380
|
+
reject(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
|
|
381
|
+
}, bodyTimeoutMs)
|
|
424
382
|
})
|
|
425
383
|
|
|
384
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
385
|
+
const contentLength = Number(upstreamRes.headers.get('content-length'))
|
|
386
|
+
if (contentLength > MAX_BODY_BYTES) {
|
|
387
|
+
clearTimeout(bodyTimer)
|
|
388
|
+
cleanupClientListeners()
|
|
389
|
+
writeJson(res, 502, { ok: false, error: 'Upstream response exceeded maximum allowed size' })
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
426
393
|
let resText = ''
|
|
427
394
|
let readError = null
|
|
428
395
|
try {
|
|
@@ -430,6 +397,9 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
430
397
|
upstreamRes.text(),
|
|
431
398
|
bodyTimeoutPromise,
|
|
432
399
|
])
|
|
400
|
+
if (resText.length > MAX_BODY_BYTES) {
|
|
401
|
+
throw new Error('Upstream response exceeded maximum allowed size')
|
|
402
|
+
}
|
|
433
403
|
} catch (err) {
|
|
434
404
|
readError = err
|
|
435
405
|
} finally {
|
|
@@ -452,7 +422,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
452
422
|
})
|
|
453
423
|
const statusCode = isTimeout ? 504 : 502
|
|
454
424
|
const errMessage = isTimeout
|
|
455
|
-
? `Gateway Timeout: upstream body read timed out after ${
|
|
425
|
+
? `Gateway Timeout: upstream body read timed out after ${bodyTimeoutMs}ms`
|
|
456
426
|
: `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
|
|
457
427
|
return writeJson(res, statusCode, {
|
|
458
428
|
error: {
|
|
@@ -532,51 +502,67 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
532
502
|
details: lastError?.message || null
|
|
533
503
|
}
|
|
534
504
|
})
|
|
535
|
-
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function createModelsHandler(ctx, { live }) {
|
|
509
|
+
return async (req, res) => {
|
|
510
|
+
if (req.method !== 'GET') {
|
|
511
|
+
return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) {
|
|
515
|
+
return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (hasDisallowedBrowserOrigin(req)) {
|
|
519
|
+
return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const pool = await resolveAccountPool(ctx, live())
|
|
523
|
+
if (!isAuthorizedProxyRequest(req, pool)) {
|
|
524
|
+
return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const pub = publicConfig(live())
|
|
528
|
+
const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
|
|
529
|
+
const custom = Array.isArray(pub.customModels) ? pub.customModels : []
|
|
530
|
+
const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
|
|
531
|
+
const allModels = getAllModels(dynamic, overrides, custom)
|
|
532
|
+
|
|
533
|
+
const modelsData = allModels.map(m => ({
|
|
534
|
+
id: m.id,
|
|
535
|
+
object: 'model',
|
|
536
|
+
created: 1700000000,
|
|
537
|
+
owned_by: 'clinebot',
|
|
538
|
+
permission: [],
|
|
539
|
+
root: m.id,
|
|
540
|
+
parent: null
|
|
541
|
+
}))
|
|
542
|
+
|
|
543
|
+
writeJson(res, 200, {
|
|
544
|
+
object: 'list',
|
|
545
|
+
data: modelsData
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function registerProxyRoutes(ctx, routeEnv) {
|
|
551
|
+
const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
|
|
552
|
+
const modelsHandler = createModelsHandler(ctx, routeEnv)
|
|
553
|
+
|
|
554
|
+
// 1. POST /dsh-clinebot/v1/chat/completions
|
|
555
|
+
ctx.effect(() => ctx.webServer.register({
|
|
556
|
+
kind: 'exact',
|
|
557
|
+
path: '/dsh-clinebot/v1/chat/completions',
|
|
558
|
+
handler: chatHandler,
|
|
536
559
|
}), 'dsh-clinebot: /v1/chat/completions')
|
|
537
560
|
|
|
538
561
|
// 2. GET /dsh-clinebot/v1/models (OpenAI standard)
|
|
539
562
|
ctx.effect(() => ctx.webServer.register({
|
|
540
563
|
kind: 'exact',
|
|
541
564
|
path: '/dsh-clinebot/v1/models',
|
|
542
|
-
handler:
|
|
543
|
-
if (req.method !== 'GET') {
|
|
544
|
-
return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress)) {
|
|
548
|
-
return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if (hasDisallowedBrowserOrigin(req)) {
|
|
552
|
-
return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
const pool = await resolveAccountPool(ctx, live())
|
|
556
|
-
if (!isAuthorizedProxyRequest(req, pool)) {
|
|
557
|
-
return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const pub = publicConfig(live())
|
|
561
|
-
const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
|
|
562
|
-
const custom = Array.isArray(pub.customModels) ? pub.customModels : []
|
|
563
|
-
const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
|
|
564
|
-
const allModels = getAllModels(dynamic, overrides, custom)
|
|
565
|
-
|
|
566
|
-
const modelsData = allModels.map(m => ({
|
|
567
|
-
id: m.id,
|
|
568
|
-
object: 'model',
|
|
569
|
-
created: 1700000000,
|
|
570
|
-
owned_by: 'clinebot',
|
|
571
|
-
permission: [],
|
|
572
|
-
root: m.id,
|
|
573
|
-
parent: null
|
|
574
|
-
}))
|
|
575
|
-
|
|
576
|
-
writeJson(res, 200, {
|
|
577
|
-
object: 'list',
|
|
578
|
-
data: modelsData
|
|
579
|
-
})
|
|
580
|
-
},
|
|
565
|
+
handler: modelsHandler,
|
|
581
566
|
}), 'dsh-clinebot: /v1/models')
|
|
582
567
|
}
|
|
568
|
+
|
package/lib/slash-command.js
CHANGED
|
@@ -26,7 +26,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
26
26
|
const commands = cmdCtx.commands
|
|
27
27
|
if (typeof commands?.register !== 'function') return
|
|
28
28
|
|
|
29
|
-
const executeCommand = async (rawArgs
|
|
29
|
+
const executeCommand = async (rawArgs) => {
|
|
30
30
|
try {
|
|
31
31
|
const pub = publicConfig(live())
|
|
32
32
|
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
|
|
@@ -102,12 +102,12 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
102
102
|
if (settingsApi?.replace) {
|
|
103
103
|
const curr = plainConfig(live())
|
|
104
104
|
const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
|
|
105
|
-
const next = Config({
|
|
105
|
+
const next = plainConfig(Config({
|
|
106
106
|
...curr,
|
|
107
107
|
activeAccount: param,
|
|
108
108
|
dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
|
|
109
109
|
planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
|
|
110
|
-
})
|
|
110
|
+
}))
|
|
111
111
|
await settingsApi.replace(next)
|
|
112
112
|
await syncProviderState(next)
|
|
113
113
|
return `Active account switched to ${param}`
|
|
@@ -276,7 +276,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
276
276
|
? invocation
|
|
277
277
|
: (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
|
|
278
278
|
try {
|
|
279
|
-
const output = await executeCommand(rawArgs
|
|
279
|
+
const output = await executeCommand(rawArgs)
|
|
280
280
|
return {
|
|
281
281
|
kind: 'success',
|
|
282
282
|
text: output,
|
package/lib/stats-storage.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
@@ -104,11 +104,25 @@ export function loadStats(customPath) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
} catch (err) {
|
|
107
|
-
// If reading fails or file is corrupted, preserve corrupted file for diagnostics
|
|
107
|
+
// If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
|
|
108
108
|
try {
|
|
109
109
|
if (existsSync(filePath)) {
|
|
110
110
|
const corruptBackup = `${filePath}.corrupt.${Date.now()}`
|
|
111
111
|
copyFileSync(filePath, corruptBackup)
|
|
112
|
+
const dir = path.dirname(filePath)
|
|
113
|
+
const base = path.basename(filePath)
|
|
114
|
+
const prefix = `${base}.corrupt.`
|
|
115
|
+
const existing = readdirSync(dir)
|
|
116
|
+
.filter((name) => name.startsWith(prefix))
|
|
117
|
+
.sort()
|
|
118
|
+
while (existing.length > 3) {
|
|
119
|
+
const oldest = existing.shift()
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(path.join(dir, oldest))
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore deletion failure */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
112
126
|
}
|
|
113
127
|
} catch {
|
|
114
128
|
/* ignore backup failure */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.17",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|