@goodandready/dsh-clinebot 0.4.7 → 0.4.8
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 +9 -0
- package/lib/client.js +411 -53
- package/lib/config.js +21 -3
- package/lib/index.js +35 -0
- package/lib/models.js +31 -6
- package/lib/provider-sync.js +50 -25
- package/lib/routes/proxy.js +262 -0
- package/lib/routes/settings.js +34 -0
- package/lib/session-router.js +188 -0
- package/lib/stats-storage.js +217 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
getActiveModelIds,
|
|
13
13
|
} from './models.js'
|
|
14
14
|
|
|
15
|
-
|
|
16
15
|
// DSH settings.describe only publishes fields marked volatile. Without them the
|
|
17
16
|
// namespace is omitted and the configuration page stays unavailable.
|
|
18
17
|
if (typeof z.prototype?.volatile !== 'function') {
|
|
@@ -58,6 +57,21 @@ export const Config = z.object({
|
|
|
58
57
|
maxTokens: z.number().default(8192),
|
|
59
58
|
})).default([])
|
|
60
59
|
.description('Custom user-defined model context length and max token overrides.').volatile(),
|
|
60
|
+
modelReasoningDefaults: z.dict(z.string()).default({})
|
|
61
|
+
.description('Configured default reasoning effort per model (low, medium, high, max).').volatile(),
|
|
62
|
+
customModels: z.array(z.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
name: z.string(),
|
|
65
|
+
contextLength: z.number().default(128000),
|
|
66
|
+
maxTokens: z.number().default(8192),
|
|
67
|
+
category: z.string().default('general'),
|
|
68
|
+
isReasoning: z.boolean().default(false),
|
|
69
|
+
})).default([])
|
|
70
|
+
.description('User-defined custom gateway models.').volatile(),
|
|
71
|
+
proxyMode: z.boolean().default(true)
|
|
72
|
+
.description('When true, registers local transparent loopback proxy for failover and token tracking.').volatile(),
|
|
73
|
+
statsPath: z.string().default('~/.dsh/clinebot-stats.json')
|
|
74
|
+
.description('Local on-disk persistent path for token usage analytics.').volatile(),
|
|
61
75
|
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
|
|
62
76
|
.description('HTTP probe timeout in milliseconds.').volatile(),
|
|
63
77
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
@@ -73,7 +87,6 @@ export const Config = z.object({
|
|
|
73
87
|
.description('Manually pinned active account envName or empty for auto/default.').volatile(),
|
|
74
88
|
})
|
|
75
89
|
|
|
76
|
-
|
|
77
90
|
function isVolatileRef(value) {
|
|
78
91
|
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
79
92
|
}
|
|
@@ -111,9 +124,10 @@ export function volatileConfig(cfg) {
|
|
|
111
124
|
export function publicConfig(cfg) {
|
|
112
125
|
cfg = plainConfig(cfg)
|
|
113
126
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
127
|
+
const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
|
|
114
128
|
const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
|
|
115
129
|
const isSynced = dynamic.length > 0
|
|
116
|
-
const allModels = getAllModels(dynamic, modelContextOverrides)
|
|
130
|
+
const allModels = getAllModels(dynamic, modelContextOverrides, custom)
|
|
117
131
|
const allDefaultIds = allModels.map((m) => m.id)
|
|
118
132
|
|
|
119
133
|
let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
|
|
@@ -148,6 +162,10 @@ export function publicConfig(cfg) {
|
|
|
148
162
|
planSynced: isSynced,
|
|
149
163
|
planSyncedAt,
|
|
150
164
|
modelContextOverrides,
|
|
165
|
+
modelReasoningDefaults: cfg?.modelReasoningDefaults && typeof cfg.modelReasoningDefaults === 'object' ? cfg.modelReasoningDefaults : {},
|
|
166
|
+
customModels: custom,
|
|
167
|
+
proxyMode: cfg?.proxyMode !== false,
|
|
168
|
+
statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
|
|
151
169
|
disabledModels: disabledList,
|
|
152
170
|
enabledModels: activeIds,
|
|
153
171
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
package/lib/index.js
CHANGED
|
@@ -18,7 +18,9 @@ import { registerSettingsRoutes } from './routes/settings.js'
|
|
|
18
18
|
import { registerAccountsRoutes } from './routes/accounts.js'
|
|
19
19
|
import { registerModelsRoutes } from './routes/models.js'
|
|
20
20
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
21
|
+
import { registerProxyRoutes } from './routes/proxy.js'
|
|
21
22
|
import { registerSlashCommand } from './slash-command.js'
|
|
23
|
+
import { recordUsage } from './stats-storage.js'
|
|
22
24
|
|
|
23
25
|
export const name = '@goodandready/dsh-clinebot'
|
|
24
26
|
export const inject = ['webServer', 'credentials']
|
|
@@ -135,6 +137,7 @@ export function apply(ctx, config) {
|
|
|
135
137
|
registerAccountsRoutes(ctx, routeEnv)
|
|
136
138
|
registerModelsRoutes(ctx, routeEnv)
|
|
137
139
|
registerAuthRoutes(ctx, routeEnv)
|
|
140
|
+
registerProxyRoutes(ctx, routeEnv)
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
registerSlashCommand(ctx, {
|
|
@@ -198,6 +201,15 @@ export function apply(ctx, config) {
|
|
|
198
201
|
completionTokens,
|
|
199
202
|
model: options?.model,
|
|
200
203
|
})
|
|
204
|
+
recordUsage({
|
|
205
|
+
model: options?.model,
|
|
206
|
+
promptTokens,
|
|
207
|
+
completionTokens,
|
|
208
|
+
totalTokens: promptTokens + completionTokens,
|
|
209
|
+
isError: true,
|
|
210
|
+
is429,
|
|
211
|
+
statsPath: live().statsPath,
|
|
212
|
+
})
|
|
201
213
|
if (is429 || isExceeded) {
|
|
202
214
|
handleStream429()
|
|
203
215
|
}
|
|
@@ -218,6 +230,13 @@ export function apply(ctx, config) {
|
|
|
218
230
|
completionTokens,
|
|
219
231
|
model: options?.model,
|
|
220
232
|
})
|
|
233
|
+
recordUsage({
|
|
234
|
+
model: options?.model,
|
|
235
|
+
promptTokens,
|
|
236
|
+
completionTokens,
|
|
237
|
+
totalTokens: promptTokens + completionTokens,
|
|
238
|
+
statsPath: live().statsPath,
|
|
239
|
+
})
|
|
221
240
|
}
|
|
222
241
|
}
|
|
223
242
|
yield chunk
|
|
@@ -230,6 +249,13 @@ export function apply(ctx, config) {
|
|
|
230
249
|
completionTokens,
|
|
231
250
|
model: options?.model,
|
|
232
251
|
})
|
|
252
|
+
recordUsage({
|
|
253
|
+
model: options?.model,
|
|
254
|
+
promptTokens,
|
|
255
|
+
completionTokens,
|
|
256
|
+
totalTokens: promptTokens + completionTokens,
|
|
257
|
+
statsPath: live().statsPath,
|
|
258
|
+
})
|
|
233
259
|
}
|
|
234
260
|
} catch (err) {
|
|
235
261
|
if (isCline) {
|
|
@@ -244,6 +270,15 @@ export function apply(ctx, config) {
|
|
|
244
270
|
completionTokens,
|
|
245
271
|
model: options?.model,
|
|
246
272
|
})
|
|
273
|
+
recordUsage({
|
|
274
|
+
model: options?.model,
|
|
275
|
+
promptTokens,
|
|
276
|
+
completionTokens,
|
|
277
|
+
totalTokens: promptTokens + completionTokens,
|
|
278
|
+
isError: true,
|
|
279
|
+
is429: status === 429,
|
|
280
|
+
statsPath: live().statsPath,
|
|
281
|
+
})
|
|
247
282
|
if (status === 429 || isQuotaExceededError(msg)) {
|
|
248
283
|
handleStream429()
|
|
249
284
|
}
|
package/lib/models.js
CHANGED
|
@@ -336,7 +336,7 @@ export function parsePlanIncludedModels(includedInput) {
|
|
|
336
336
|
*
|
|
337
337
|
* Applies user-defined modelContextOverrides (custom contextLength and maxTokens).
|
|
338
338
|
*/
|
|
339
|
-
export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
|
|
339
|
+
export function getAllModels(dynamicModels = [], modelContextOverrides = [], customModels = []) {
|
|
340
340
|
const normalizedDynamic = (Array.isArray(dynamicModels) ? dynamicModels : []).map(migrateModelEntry)
|
|
341
341
|
const overridesMap = new Map()
|
|
342
342
|
if (Array.isArray(modelContextOverrides)) {
|
|
@@ -374,9 +374,10 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
|
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
let baseModels = []
|
|
377
378
|
if (normalizedDynamic.length > 0) {
|
|
378
379
|
const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
|
|
379
|
-
|
|
380
|
+
baseModels = normalizedDynamic.map((dm) => {
|
|
380
381
|
const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
|
|
381
382
|
const curated = CLINE_MODELS.find(
|
|
382
383
|
(cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
|
|
@@ -407,23 +408,47 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = []) {
|
|
|
407
408
|
unverified: false,
|
|
408
409
|
})
|
|
409
410
|
})
|
|
411
|
+
} else {
|
|
412
|
+
baseModels = CLINE_MODELS.map((m) => applyOverride({ ...m, unverified: true }))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (Array.isArray(customModels) && customModels.length > 0) {
|
|
416
|
+
const existingIds = new Set(baseModels.map((m) => m.id))
|
|
417
|
+
for (const cm of customModels) {
|
|
418
|
+
if (cm && cm.id && !existingIds.has(cm.id)) {
|
|
419
|
+
const fullCustom = {
|
|
420
|
+
id: cm.id,
|
|
421
|
+
name: cm.name || cm.id,
|
|
422
|
+
description: cm.description || `Custom model ${cm.name || cm.id}`,
|
|
423
|
+
contextLength: cm.contextLength || 128000,
|
|
424
|
+
maxTokens: cm.maxTokens || 8192,
|
|
425
|
+
input: ['text'],
|
|
426
|
+
category: cm.category || 'general',
|
|
427
|
+
isCustom: true,
|
|
428
|
+
reasoningEfforts: cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined,
|
|
429
|
+
unverified: false,
|
|
430
|
+
}
|
|
431
|
+
baseModels.push(applyOverride(fullCustom))
|
|
432
|
+
existingIds.add(cm.id)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
410
435
|
}
|
|
411
436
|
|
|
412
|
-
return
|
|
437
|
+
return baseModels
|
|
413
438
|
}
|
|
414
439
|
|
|
415
|
-
export function findModel(id, dynamicModels = [], modelContextOverrides = []) {
|
|
440
|
+
export function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
|
|
416
441
|
const all = getAllModels(dynamicModels, modelContextOverrides)
|
|
417
442
|
const found = all.find((m) => m.id === id)
|
|
418
443
|
if (found) return found
|
|
419
444
|
return CLINE_MODELS.find((m) => m.id === id) || null
|
|
420
445
|
}
|
|
421
446
|
|
|
422
|
-
export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = []) {
|
|
447
|
+
export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
|
|
423
448
|
return findModel(id, dynamicModels, modelContextOverrides) !== null
|
|
424
449
|
}
|
|
425
450
|
|
|
426
|
-
export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = []) {
|
|
451
|
+
export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
|
|
427
452
|
return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
|
|
428
453
|
}
|
|
429
454
|
|
package/lib/provider-sync.js
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
import os from 'node:os'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
|
|
4
|
-
import { publicUsage } from './http.js'
|
|
5
3
|
import {
|
|
6
4
|
PROVIDER_ID,
|
|
7
5
|
PROVIDER_DISPLAY_NAME,
|
|
8
6
|
getAllModels,
|
|
9
|
-
loadModelsDiskCache,
|
|
10
7
|
saveModelsDiskCache,
|
|
8
|
+
loadModelsDiskCache,
|
|
9
|
+
OBSOLETE_MODEL_ID_MAP,
|
|
10
|
+
migrateModelEntry,
|
|
11
11
|
} from './models.js'
|
|
12
12
|
import {
|
|
13
|
-
resolveKeyValue,
|
|
14
|
-
resolveAccountPool,
|
|
15
13
|
probeHealth,
|
|
16
14
|
fetchUsageLimits,
|
|
17
|
-
buildPiAiProvider,
|
|
18
15
|
sessionStats,
|
|
19
|
-
getLastRotation,
|
|
20
16
|
usageCache,
|
|
17
|
+
buildPiAiProvider,
|
|
21
18
|
DEFAULT_API_KEY_ENV,
|
|
22
19
|
} from './cline-client.js'
|
|
20
|
+
import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
|
|
21
|
+
import { publicUsage } from './http.js'
|
|
22
|
+
import { resolveKeyValue } from './credential-refs.js'
|
|
23
|
+
import { resolveAccountPool, getLastRotation } from './account-pool.js'
|
|
23
24
|
|
|
24
25
|
export function resolvePathWithHome(p) {
|
|
25
|
-
if (
|
|
26
|
-
if (p === '~') return os.homedir()
|
|
27
|
-
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
26
|
+
if (typeof p === 'string' && p.startsWith('~/')) {
|
|
28
27
|
return path.join(os.homedir(), p.slice(2))
|
|
29
28
|
}
|
|
30
29
|
return p
|
|
@@ -88,7 +87,7 @@ export async function buildStatus(ctx, cfg) {
|
|
|
88
87
|
])
|
|
89
88
|
|
|
90
89
|
const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
|
|
91
|
-
const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
|
|
90
|
+
const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
|
|
92
91
|
|
|
93
92
|
let usage = null
|
|
94
93
|
const keyToUse = activeAcc.value || key.value
|
|
@@ -97,19 +96,40 @@ export async function buildStatus(ctx, cfg) {
|
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
let quotaWarning = null
|
|
100
|
-
if (usage?.windows
|
|
101
|
-
const
|
|
102
|
-
|
|
99
|
+
if (usage?.windows) {
|
|
100
|
+
const fiveHour = usage.windows.fiveHour
|
|
101
|
+
const weekly = usage.windows.weekly
|
|
102
|
+
const monthly = usage.windows.monthly
|
|
103
|
+
|
|
104
|
+
if (fiveHour && typeof fiveHour.percentUsed === 'number') {
|
|
105
|
+
if (fiveHour.percentUsed >= 95) {
|
|
106
|
+
quotaWarning = {
|
|
107
|
+
level: 'exhausted',
|
|
108
|
+
message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
|
|
109
|
+
resetsAt: fiveHour.resetsAt,
|
|
110
|
+
}
|
|
111
|
+
} else if (fiveHour.percentUsed >= 90) {
|
|
112
|
+
quotaWarning = {
|
|
113
|
+
level: 'warning',
|
|
114
|
+
message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
|
|
115
|
+
resetsAt: fiveHour.resetsAt,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
|
|
103
121
|
quotaWarning = {
|
|
104
|
-
level: 'exhausted',
|
|
105
|
-
message: `
|
|
106
|
-
resetsAt:
|
|
122
|
+
level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
|
|
123
|
+
message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${weekly.remainingPercent ?? (100 - weekly.percentUsed)}% remaining).`,
|
|
124
|
+
resetsAt: weekly.resetsAt,
|
|
107
125
|
}
|
|
108
|
-
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
|
|
109
129
|
quotaWarning = {
|
|
110
|
-
level: 'warning',
|
|
111
|
-
message: `
|
|
112
|
-
resetsAt:
|
|
130
|
+
level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
|
|
131
|
+
message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${monthly.remainingPercent ?? (100 - monthly.percentUsed)}% remaining).`,
|
|
132
|
+
resetsAt: monthly.resetsAt,
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
135
|
}
|
|
@@ -127,7 +147,7 @@ export async function buildStatus(ctx, cfg) {
|
|
|
127
147
|
accounts: pool.map((acc) => {
|
|
128
148
|
const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
|
|
129
149
|
const cached = usageCache.get(cacheKey)?.data
|
|
130
|
-
const pct = cached?.windows?.fiveHour?.percentUsed
|
|
150
|
+
const pct = cached?.windows?.weekly?.percentUsed ?? cached?.windows?.monthly?.percentUsed ?? cached?.windows?.fiveHour?.percentUsed ?? null
|
|
131
151
|
return {
|
|
132
152
|
id: acc.id,
|
|
133
153
|
label: acc.label,
|
|
@@ -156,17 +176,22 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
|
156
176
|
}
|
|
157
177
|
|
|
158
178
|
const pub = publicConfig(cfg)
|
|
159
|
-
const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
|
|
179
|
+
const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
|
|
160
180
|
const allowedSet = new Set(activeModelIds || pub.enabledModels)
|
|
161
181
|
const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
|
|
162
182
|
|
|
163
183
|
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
164
184
|
|
|
185
|
+
const port = ctx?.webServer?.port || process.env.PORT || 3080
|
|
186
|
+
const effectiveBaseUrl = pub.proxyMode !== false
|
|
187
|
+
? `http://127.0.0.1:${port}/dsh-clinebot/v1`
|
|
188
|
+
: pub.baseUrl
|
|
189
|
+
|
|
165
190
|
const providerObj = buildPiAiProvider({
|
|
166
|
-
baseUrl:
|
|
191
|
+
baseUrl: effectiveBaseUrl,
|
|
167
192
|
apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
|
|
168
193
|
models: modelsToRegister.length ? modelsToRegister : allModels,
|
|
169
|
-
customModels: pub.dynamicModels,
|
|
194
|
+
customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
|
|
170
195
|
displayName: PROVIDER_DISPLAY_NAME,
|
|
171
196
|
})
|
|
172
197
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transparent Loopback Proxy & Zero-Downtime Failover (Issue #127)
|
|
3
|
+
*
|
|
4
|
+
* Exposes local OpenAI-compatible endpoints:
|
|
5
|
+
* - POST /dsh-clinebot/v1/chat/completions
|
|
6
|
+
* - GET /dsh-clinebot/v1/models
|
|
7
|
+
*
|
|
8
|
+
* Implements:
|
|
9
|
+
* 1. Sticky session routing with least-used quota allocation.
|
|
10
|
+
* 2. Transparent upstream retry on 429 / quota exhaustion before first chunk is streamed.
|
|
11
|
+
* 3. Accurate streaming SSE token usage interception & persistent telemetry recording.
|
|
12
|
+
* 4. Model reasoning_effort default injection from config.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeJson, readBody } from '../http.js'
|
|
16
|
+
import { publicConfig, plainConfig } from '../config.js'
|
|
17
|
+
import { resolveAccountPool } from '../account-pool.js'
|
|
18
|
+
import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
|
|
19
|
+
import { recordUsage } from '../stats-storage.js'
|
|
20
|
+
import { getAllModels } from '../models.js'
|
|
21
|
+
|
|
22
|
+
export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
23
|
+
// 1. POST /dsh-clinebot/v1/chat/completions
|
|
24
|
+
ctx.effect(() => ctx.webServer.register({
|
|
25
|
+
kind: 'exact',
|
|
26
|
+
path: '/dsh-clinebot/v1/chat/completions',
|
|
27
|
+
handler: async (req, res) => {
|
|
28
|
+
if (req.method !== 'POST') {
|
|
29
|
+
return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let bodyBuf
|
|
33
|
+
try {
|
|
34
|
+
bodyBuf = await readBody(req)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let body
|
|
40
|
+
try {
|
|
41
|
+
body = JSON.parse(bodyBuf.toString('utf8') || '{}')
|
|
42
|
+
} catch {
|
|
43
|
+
return writeJson(res, 400, { ok: false, error: 'Invalid JSON request body' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cfg = plainConfig(live())
|
|
47
|
+
const pub = publicConfig(cfg)
|
|
48
|
+
|
|
49
|
+
// Session identification
|
|
50
|
+
const sessionId = String(
|
|
51
|
+
req.headers['x-dsh-session-id'] ||
|
|
52
|
+
req.headers['x-session-id'] ||
|
|
53
|
+
body?.conversation_id ||
|
|
54
|
+
body?.chat_id ||
|
|
55
|
+
body?.user ||
|
|
56
|
+
''
|
|
57
|
+
).trim()
|
|
58
|
+
|
|
59
|
+
// Inject model reasoning effort if model has default configured and payload omitted it
|
|
60
|
+
if (
|
|
61
|
+
pub.modelReasoningDefaults &&
|
|
62
|
+
body.model &&
|
|
63
|
+
pub.modelReasoningDefaults[body.model] &&
|
|
64
|
+
!body.reasoning_effort
|
|
65
|
+
) {
|
|
66
|
+
body.reasoning_effort = pub.modelReasoningDefaults[body.model]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
70
|
+
const isStreaming = Boolean(body.stream) || String(req.headers.accept || '').includes('text/event-stream')
|
|
71
|
+
|
|
72
|
+
// Transparent retry loop (up to max configured accounts, min 1, max 3)
|
|
73
|
+
const maxRetries = Math.min(Math.max(1, pool.length), 3)
|
|
74
|
+
let attempt = 0
|
|
75
|
+
let lastError = null
|
|
76
|
+
let targetAccount = null
|
|
77
|
+
|
|
78
|
+
while (attempt < maxRetries) {
|
|
79
|
+
attempt++
|
|
80
|
+
const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
|
|
81
|
+
targetAccount = routeResult.account
|
|
82
|
+
|
|
83
|
+
if (!targetAccount || !targetAccount.value) {
|
|
84
|
+
return writeJson(res, 503, {
|
|
85
|
+
error: {
|
|
86
|
+
message: 'No available ClinePass API keys configured in pool',
|
|
87
|
+
type: 'service_unavailable',
|
|
88
|
+
code: 'no_keys_available'
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const upstreamUrl = `${pub.baseUrl}/chat/completions`
|
|
94
|
+
const requestHeaders = {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'Authorization': `Bearer ${targetAccount.value}`,
|
|
97
|
+
'Accept': isStreaming ? 'text/event-stream' : 'application/json',
|
|
98
|
+
'User-Agent': '@goodandready/dsh-clinebot/0.4.8'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let upstreamRes
|
|
102
|
+
try {
|
|
103
|
+
upstreamRes = await fetch(upstreamUrl, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: requestHeaders,
|
|
106
|
+
body: JSON.stringify(body),
|
|
107
|
+
keepalive: true
|
|
108
|
+
})
|
|
109
|
+
} catch (fetchErr) {
|
|
110
|
+
lastError = fetchErr
|
|
111
|
+
// Network errors: retry with next account if available
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If upstream rejected with 429 or 403 quota exhaustion before streaming, failover!
|
|
116
|
+
if (upstreamRes.status === 429 || upstreamRes.status === 402) {
|
|
117
|
+
const accountId = targetAccount.id || targetAccount.label
|
|
118
|
+
markAccountCooldown(accountId, 60000, null, 'upstream_429')
|
|
119
|
+
recordUsage({
|
|
120
|
+
model: body.model,
|
|
121
|
+
accountId,
|
|
122
|
+
is429: true,
|
|
123
|
+
isError: true,
|
|
124
|
+
statsPath: pub.statsPath
|
|
125
|
+
})
|
|
126
|
+
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" hit 429, attempting failover (attempt ${attempt}/${maxRetries})`)
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Upstream responded with non-retryable response: stream or write JSON to client
|
|
131
|
+
if (isStreaming && upstreamRes.status === 200 && upstreamRes.body) {
|
|
132
|
+
res.statusCode = 200
|
|
133
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
134
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
135
|
+
res.setHeader('Connection', 'keep-alive')
|
|
136
|
+
if (typeof res.flushHeaders === 'function') {
|
|
137
|
+
res.flushHeaders()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let promptTokens = 0
|
|
141
|
+
let completionTokens = 0
|
|
142
|
+
let totalTokens = 0
|
|
143
|
+
let buffer = ''
|
|
144
|
+
|
|
145
|
+
const reader = upstreamRes.body.getReader()
|
|
146
|
+
const decoder = new TextDecoder('utf8')
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
while (true) {
|
|
150
|
+
const { done, value } = await reader.read()
|
|
151
|
+
if (done) break
|
|
152
|
+
|
|
153
|
+
const chunkStr = decoder.decode(value, { stream: true })
|
|
154
|
+
res.write(value)
|
|
155
|
+
|
|
156
|
+
// Parse SSE lines for usage metrics
|
|
157
|
+
buffer += chunkStr
|
|
158
|
+
const lines = buffer.split('\n')
|
|
159
|
+
buffer = lines.pop() // keep incomplete last line
|
|
160
|
+
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const trimmed = line.trim()
|
|
163
|
+
if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(trimmed.slice(6))
|
|
166
|
+
if (data?.usage) {
|
|
167
|
+
promptTokens = Number(data.usage.prompt_tokens) || promptTokens
|
|
168
|
+
completionTokens = Number(data.usage.completion_tokens) || completionTokens
|
|
169
|
+
totalTokens = Number(data.usage.total_tokens) || totalTokens
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
/* ignore partial or non-json SSE lines */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (streamErr) {
|
|
178
|
+
ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
|
|
179
|
+
} finally {
|
|
180
|
+
res.end()
|
|
181
|
+
recordUsage({
|
|
182
|
+
model: body.model,
|
|
183
|
+
accountId: targetAccount.id || targetAccount.label,
|
|
184
|
+
promptTokens,
|
|
185
|
+
completionTokens,
|
|
186
|
+
totalTokens,
|
|
187
|
+
statsPath: pub.statsPath
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Non-streaming JSON response or error status from upstream
|
|
194
|
+
const resText = await upstreamRes.text()
|
|
195
|
+
let resJson = null
|
|
196
|
+
try {
|
|
197
|
+
resJson = JSON.parse(resText)
|
|
198
|
+
} catch {
|
|
199
|
+
resJson = null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (resJson?.usage) {
|
|
203
|
+
recordUsage({
|
|
204
|
+
model: body.model,
|
|
205
|
+
accountId: targetAccount.id || targetAccount.label,
|
|
206
|
+
promptTokens: resJson.usage.prompt_tokens,
|
|
207
|
+
completionTokens: resJson.usage.completion_tokens,
|
|
208
|
+
totalTokens: resJson.usage.total_tokens,
|
|
209
|
+
isError: upstreamRes.status >= 400,
|
|
210
|
+
statsPath: pub.statsPath
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
res.statusCode = upstreamRes.status
|
|
215
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
216
|
+
res.end(resText)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Exhausted all retries
|
|
221
|
+
writeJson(res, 429, {
|
|
222
|
+
error: {
|
|
223
|
+
message: 'All configured accounts in pool exceeded rate limits or quota',
|
|
224
|
+
type: 'rate_limit_exceeded',
|
|
225
|
+
code: 'rate_limit_exceeded',
|
|
226
|
+
details: lastError?.message || null
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
}), 'dsh-clinebot: /v1/chat/completions')
|
|
231
|
+
|
|
232
|
+
// 2. GET /dsh-clinebot/v1/models (OpenAI standard)
|
|
233
|
+
ctx.effect(() => ctx.webServer.register({
|
|
234
|
+
kind: 'exact',
|
|
235
|
+
path: '/dsh-clinebot/v1/models',
|
|
236
|
+
handler: async (req, res) => {
|
|
237
|
+
if (req.method !== 'GET') {
|
|
238
|
+
return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
239
|
+
}
|
|
240
|
+
const pub = publicConfig(live())
|
|
241
|
+
const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
|
|
242
|
+
const custom = Array.isArray(pub.customModels) ? pub.customModels : []
|
|
243
|
+
const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
|
|
244
|
+
const allModels = getAllModels(dynamic, overrides, custom)
|
|
245
|
+
|
|
246
|
+
const modelsData = allModels.map(m => ({
|
|
247
|
+
id: m.id,
|
|
248
|
+
object: 'model',
|
|
249
|
+
created: 1700000000,
|
|
250
|
+
owned_by: 'clinebot',
|
|
251
|
+
permission: [],
|
|
252
|
+
root: m.id,
|
|
253
|
+
parent: null
|
|
254
|
+
}))
|
|
255
|
+
|
|
256
|
+
writeJson(res, 200, {
|
|
257
|
+
object: 'list',
|
|
258
|
+
data: modelsData
|
|
259
|
+
})
|
|
260
|
+
},
|
|
261
|
+
}), 'dsh-clinebot: /v1/models')
|
|
262
|
+
}
|
package/lib/routes/settings.js
CHANGED
|
@@ -3,6 +3,7 @@ import { assertTrustedSettingsRequest } from '../access.js'
|
|
|
3
3
|
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
4
4
|
import { buildStatus } from '../provider-sync.js'
|
|
5
5
|
import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
|
|
6
|
+
import { getStatsSummary, resetStats } from '../stats-storage.js'
|
|
6
7
|
|
|
7
8
|
export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
|
|
8
9
|
// 1. GET /dsh-clinebot/status
|
|
@@ -171,4 +172,37 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
171
172
|
}
|
|
172
173
|
},
|
|
173
174
|
}), 'dsh-clinebot: /save-key')
|
|
175
|
+
|
|
176
|
+
// 4. GET /dsh-clinebot/stats — persistent token analytics summary (Issue #129)
|
|
177
|
+
ctx.effect(() => ctx.webServer.register({
|
|
178
|
+
kind: 'exact',
|
|
179
|
+
path: '/dsh-clinebot/stats',
|
|
180
|
+
handler: async (req, res) => {
|
|
181
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
182
|
+
if (!assertTrustedSettingsRequest(req, res)) return
|
|
183
|
+
try {
|
|
184
|
+
const stats = getStatsSummary()
|
|
185
|
+
writeJson(res, 200, { ok: true, stats })
|
|
186
|
+
} catch (err) {
|
|
187
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}), 'dsh-clinebot: /stats')
|
|
191
|
+
|
|
192
|
+
// 5. POST /dsh-clinebot/stats/reset — reset persistent stats (Issue #129)
|
|
193
|
+
ctx.effect(() => ctx.webServer.register({
|
|
194
|
+
kind: 'exact',
|
|
195
|
+
path: '/dsh-clinebot/stats/reset',
|
|
196
|
+
handler: async (req, res) => {
|
|
197
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
198
|
+
if (!assertTrustedSettingsRequest(req, res)) return
|
|
199
|
+
try {
|
|
200
|
+
const pub = publicConfig(live())
|
|
201
|
+
const stats = resetStats(pub.statsPath)
|
|
202
|
+
writeJson(res, 200, { ok: true, stats })
|
|
203
|
+
} catch (err) {
|
|
204
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
}), 'dsh-clinebot: /stats/reset')
|
|
174
208
|
}
|