@goodandready/dsh-clinebot 0.4.6 → 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 +18 -0
- package/lib/client.js +411 -53
- package/lib/config.js +21 -4
- package/lib/index.js +35 -5
- 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 +35 -1
- package/lib/session-router.js +188 -0
- package/lib/stats-storage.js +217 -0
- package/package.json +1 -2
package/lib/config.js
CHANGED
|
@@ -9,11 +9,9 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_MODEL_ID,
|
|
11
11
|
getAllModels,
|
|
12
|
-
getDefaultModelIds,
|
|
13
12
|
getActiveModelIds,
|
|
14
13
|
} from './models.js'
|
|
15
14
|
|
|
16
|
-
|
|
17
15
|
// DSH settings.describe only publishes fields marked volatile. Without them the
|
|
18
16
|
// namespace is omitted and the configuration page stays unavailable.
|
|
19
17
|
if (typeof z.prototype?.volatile !== 'function') {
|
|
@@ -59,6 +57,21 @@ export const Config = z.object({
|
|
|
59
57
|
maxTokens: z.number().default(8192),
|
|
60
58
|
})).default([])
|
|
61
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(),
|
|
62
75
|
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
|
|
63
76
|
.description('HTTP probe timeout in milliseconds.').volatile(),
|
|
64
77
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
@@ -74,7 +87,6 @@ export const Config = z.object({
|
|
|
74
87
|
.description('Manually pinned active account envName or empty for auto/default.').volatile(),
|
|
75
88
|
})
|
|
76
89
|
|
|
77
|
-
|
|
78
90
|
function isVolatileRef(value) {
|
|
79
91
|
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
80
92
|
}
|
|
@@ -112,9 +124,10 @@ export function volatileConfig(cfg) {
|
|
|
112
124
|
export function publicConfig(cfg) {
|
|
113
125
|
cfg = plainConfig(cfg)
|
|
114
126
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
127
|
+
const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
|
|
115
128
|
const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
|
|
116
129
|
const isSynced = dynamic.length > 0
|
|
117
|
-
const allModels = getAllModels(dynamic, modelContextOverrides)
|
|
130
|
+
const allModels = getAllModels(dynamic, modelContextOverrides, custom)
|
|
118
131
|
const allDefaultIds = allModels.map((m) => m.id)
|
|
119
132
|
|
|
120
133
|
let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
|
|
@@ -149,6 +162,10 @@ export function publicConfig(cfg) {
|
|
|
149
162
|
planSynced: isSynced,
|
|
150
163
|
planSyncedAt,
|
|
151
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'),
|
|
152
169
|
disabledModels: disabledList,
|
|
153
170
|
enabledModels: activeIds,
|
|
154
171
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
package/lib/index.js
CHANGED
|
@@ -5,17 +5,12 @@ import {
|
|
|
5
5
|
recordSessionRequest,
|
|
6
6
|
resetSessionStats,
|
|
7
7
|
rotateToNextAccount,
|
|
8
|
-
resolveKeyValue,
|
|
9
|
-
smokeChat,
|
|
10
|
-
fetchUsageLimits,
|
|
11
8
|
isQuotaExceededError,
|
|
12
9
|
} from './cline-client.js'
|
|
13
10
|
import { PROVIDER_ID } from './models.js'
|
|
14
11
|
import {
|
|
15
|
-
checkRegisteredInPiAi,
|
|
16
12
|
upsertPiAiProvider,
|
|
17
13
|
removePiAiProvider,
|
|
18
|
-
buildStatus,
|
|
19
14
|
autoDiscoverPlanModels,
|
|
20
15
|
resolveActiveAccountKey,
|
|
21
16
|
} from './provider-sync.js'
|
|
@@ -23,7 +18,9 @@ import { registerSettingsRoutes } from './routes/settings.js'
|
|
|
23
18
|
import { registerAccountsRoutes } from './routes/accounts.js'
|
|
24
19
|
import { registerModelsRoutes } from './routes/models.js'
|
|
25
20
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
21
|
+
import { registerProxyRoutes } from './routes/proxy.js'
|
|
26
22
|
import { registerSlashCommand } from './slash-command.js'
|
|
23
|
+
import { recordUsage } from './stats-storage.js'
|
|
27
24
|
|
|
28
25
|
export const name = '@goodandready/dsh-clinebot'
|
|
29
26
|
export const inject = ['webServer', 'credentials']
|
|
@@ -140,6 +137,7 @@ export function apply(ctx, config) {
|
|
|
140
137
|
registerAccountsRoutes(ctx, routeEnv)
|
|
141
138
|
registerModelsRoutes(ctx, routeEnv)
|
|
142
139
|
registerAuthRoutes(ctx, routeEnv)
|
|
140
|
+
registerProxyRoutes(ctx, routeEnv)
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
registerSlashCommand(ctx, {
|
|
@@ -203,6 +201,15 @@ export function apply(ctx, config) {
|
|
|
203
201
|
completionTokens,
|
|
204
202
|
model: options?.model,
|
|
205
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
|
+
})
|
|
206
213
|
if (is429 || isExceeded) {
|
|
207
214
|
handleStream429()
|
|
208
215
|
}
|
|
@@ -223,6 +230,13 @@ export function apply(ctx, config) {
|
|
|
223
230
|
completionTokens,
|
|
224
231
|
model: options?.model,
|
|
225
232
|
})
|
|
233
|
+
recordUsage({
|
|
234
|
+
model: options?.model,
|
|
235
|
+
promptTokens,
|
|
236
|
+
completionTokens,
|
|
237
|
+
totalTokens: promptTokens + completionTokens,
|
|
238
|
+
statsPath: live().statsPath,
|
|
239
|
+
})
|
|
226
240
|
}
|
|
227
241
|
}
|
|
228
242
|
yield chunk
|
|
@@ -235,6 +249,13 @@ export function apply(ctx, config) {
|
|
|
235
249
|
completionTokens,
|
|
236
250
|
model: options?.model,
|
|
237
251
|
})
|
|
252
|
+
recordUsage({
|
|
253
|
+
model: options?.model,
|
|
254
|
+
promptTokens,
|
|
255
|
+
completionTokens,
|
|
256
|
+
totalTokens: promptTokens + completionTokens,
|
|
257
|
+
statsPath: live().statsPath,
|
|
258
|
+
})
|
|
238
259
|
}
|
|
239
260
|
} catch (err) {
|
|
240
261
|
if (isCline) {
|
|
@@ -249,6 +270,15 @@ export function apply(ctx, config) {
|
|
|
249
270
|
completionTokens,
|
|
250
271
|
model: options?.model,
|
|
251
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
|
+
})
|
|
252
282
|
if (status === 429 || isQuotaExceededError(msg)) {
|
|
253
283
|
handleStream429()
|
|
254
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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { writeJson, readBody } from '../http.js'
|
|
2
|
-
import {
|
|
2
|
+
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
|
}
|