@goodandready/dsh-clinebot 0.5.32 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,7 +234,8 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
234
234
  timeoutMs: pub.timeoutMs,
235
235
  bypassCache: true,
236
236
  })
237
- writeJson(res, usageData.ok ? 200 : 502, publicUsage(usageData))
237
+ const accountLabel = activeKey.label || activeKey.apiKeyEnv || activeKey.id || ''
238
+ writeJson(res, usageData.ok ? 200 : 502, { ...(publicUsage(usageData) || {}), account: accountLabel })
238
239
  } catch (err) {
239
240
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
240
241
  }
@@ -15,19 +15,19 @@
15
15
  * 7. Streaming SSE token usage interception & persistent telemetry recording.
16
16
  */
17
17
 
18
- import { writeJson, readBody, isLoopbackAddress } from '../http.js'
18
+ import { writeJson, readBody } from '../http.js'
19
19
  import { publicConfig, plainConfig } from '../config.js'
20
- import { resolveAccountPool } from '../account-pool.js'
21
20
  import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
22
21
  import { recordUsage } from '../stats-storage.js'
23
22
  import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
23
  import { optimizePromptCacheAndBudget } from '../multimodal-budget.js'
25
24
  import {
26
25
  getLocalProxyToken, hasDisallowedBrowserOrigin, isAuthorizedProxyRequest,
26
+ assertAuthorizedProxyRequest,
27
27
  USER_AGENT, sendProxyTerminalError, extractUsageMetrics
28
28
  } from '../proxy-token.js'
29
29
 
30
- export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, USER_AGENT }
30
+ export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, assertAuthorizedProxyRequest, USER_AGENT }
31
31
 
32
32
  export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncProviderState }) {
33
33
  return async (req, res) => {
@@ -35,18 +35,9 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
35
35
  return writeJson(res, 405, { ok: false, error: 'POST only' })
36
36
  }
37
37
 
38
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
39
- return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
40
- }
41
-
42
- if (hasDisallowedBrowserOrigin(req)) {
43
- return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
44
- }
45
-
46
- const pool = await resolveAccountPool(ctx, live())
47
- if (!isAuthorizedProxyRequest(req, pool)) {
48
- return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
49
- }
38
+ const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
39
+ if (!auth.ok) return
40
+ const pool = auth.pool
50
41
 
51
42
  let bodyBuf
52
43
  try {
@@ -543,18 +534,8 @@ export function createModelsHandler(ctx, { live }) {
543
534
  return writeJson(res, 405, { ok: false, error: 'GET only' })
544
535
  }
545
536
 
546
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
547
- return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
548
- }
549
-
550
- if (hasDisallowedBrowserOrigin(req)) {
551
- return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
552
- }
553
-
554
- const pool = await resolveAccountPool(ctx, live())
555
- if (!isAuthorizedProxyRequest(req, pool)) {
556
- return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
557
- }
537
+ const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
538
+ if (!auth.ok) return
558
539
 
559
540
  const pub = publicConfig(live())
560
541
  const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
@@ -3,7 +3,8 @@ 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, exportStatsAsCsv } from '../stats-storage.js'
6
+ import { getStatsSummary, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
7
+ import { priceTable } from '../model-prices.js'
7
8
 
8
9
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
9
10
  // 1. GET /dsh-clinebot/status
@@ -221,7 +222,13 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
221
222
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
222
223
  if (!assertTrustedSettingsRequest(req, res, ctx)) return
223
224
  try {
224
- const csv = exportStatsAsCsv()
225
+ let detailed = false
226
+ try {
227
+ detailed = new URL(req.url, 'http://127.0.0.1').searchParams.get('detailed') === '1'
228
+ } catch {
229
+ detailed = false
230
+ }
231
+ const csv = exportStatsAsCsv(detailed)
225
232
  res.statusCode = 200
226
233
  res.setHeader('Content-Type', 'text/csv; charset=utf-8')
227
234
  res.setHeader('Content-Disposition', 'attachment; filename="clinebot-stats.csv"')
@@ -231,4 +238,58 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
231
238
  }
232
239
  },
233
240
  }), 'dsh-clinebot: /stats/export')
241
+
242
+ // 7. GET /dsh-clinebot/analytics — aggregated session/day/model/account report
243
+ ctx.effect(() => ctx.webServer.register({
244
+ kind: 'exact',
245
+ path: '/dsh-clinebot/analytics',
246
+ handler: async (req, res) => {
247
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
248
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
249
+ try {
250
+ let query = {}
251
+ try {
252
+ query = Object.fromEntries(new URL(req.url, 'http://127.0.0.1').searchParams.entries())
253
+ } catch {}
254
+ const parseTime = (value) => {
255
+ if (value === undefined || value === '') return undefined
256
+ const numeric = Number(value)
257
+ return Number.isFinite(numeric) ? numeric : Date.parse(value)
258
+ }
259
+ const analytics = getAnalytics({
260
+ from: parseTime(query.from),
261
+ to: parseTime(query.to),
262
+ groupBy: query.groupBy,
263
+ limit: query.limit,
264
+ model: query.model,
265
+ account: query.account,
266
+ heatmapDays: query.heatmapDays,
267
+ heatmapModel: query.heatmapModel,
268
+ })
269
+ writeJson(res, 200, { ok: true, ...analytics })
270
+ } catch (err) {
271
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
272
+ }
273
+ },
274
+ }), 'dsh-clinebot: /analytics')
275
+
276
+ // 8. GET /dsh-clinebot/prices — local cost-estimate table
277
+ ctx.effect(() => ctx.webServer.register({
278
+ kind: 'exact',
279
+ path: '/dsh-clinebot/prices',
280
+ handler: async (req, res) => {
281
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
282
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
283
+ try {
284
+ const pub = publicConfig(live())
285
+ writeJson(res, 200, {
286
+ ok: true,
287
+ ...priceTable(pub.priceOverrides || {}, pub.enabledModels || []),
288
+ updatedAt: new Date().toISOString(),
289
+ })
290
+ } catch (err) {
291
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
292
+ }
293
+ },
294
+ }), 'dsh-clinebot: /prices')
234
295
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Optional-service helpers and one-time legacy lifecycle migration.
3
+ *
4
+ * The plugin no longer hard-injects `webServer`/`credentials`, so every read
5
+ * must survive Cordis's strict-proxy guard and a headless profile that mounts
6
+ * neither service.
7
+ */
8
+
9
+ import { LLM_PI_AI_NS } from './config.js'
10
+ import { deleteCredentialKey } from './credential-refs.js'
11
+
12
+ /**
13
+ * Read an optional service without tripping Cordis's strict-proxy guard.
14
+ * @param {object | undefined} ctx
15
+ * @param {string} name
16
+ * @returns {unknown}
17
+ */
18
+ export function service(ctx, name) {
19
+ try {
20
+ const viaGet = typeof ctx?.get === 'function' ? ctx.get(name) : undefined
21
+ if (viaGet) return viaGet
22
+ } catch {
23
+ /* strict context refuses get() for a service the fiber does not inject */
24
+ }
25
+ try {
26
+ return ctx ? ctx[name] : undefined
27
+ } catch {
28
+ return undefined
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Remove the legacy `llm-pi-ai.providers.clinebot` entry and its local proxy
34
+ * token once the native adapter owns the route. Idempotent and best-effort:
35
+ * a failed cleanup is logged, never fatal.
36
+ * @param {object} ctx
37
+ */
38
+ export async function migrateLegacyPiAiProvider(ctx) {
39
+ try {
40
+ const svc = service(ctx, 'settings')
41
+ if (!svc || typeof svc.describe !== 'function' || typeof svc.mutate !== 'function') return
42
+ const descriptor = svc.describe().find((entry) => entry?.ns === LLM_PI_AI_NS)
43
+ if (descriptor?.value?.providers?.clinebot) {
44
+ await svc.mutate(LLM_PI_AI_NS, [{ op: 'remove', path: ['providers', 'clinebot'] }])
45
+ ctx?.logger?.info?.('[dsh-clinebot] Removed legacy llm-pi-ai clinebot provider after adapter migration')
46
+ }
47
+ } catch (error) {
48
+ ctx?.logger?.warn?.('[dsh-clinebot] Legacy llm-pi-ai provider cleanup skipped: ' + (error?.message || error))
49
+ }
50
+ try {
51
+ await deleteCredentialKey(ctx, 'CLINEBOT_LOCAL_PROXY_TOKEN')
52
+ } catch {
53
+ /* the credential may never have existed */
54
+ }
55
+ }