@goodandready/dsh-clinebot 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * ClineBot / ClinePass client helpers for DeepSeek Harness.
3
+ *
4
+ * Implements OpenAI-compatible chat completions interface, usage quota tracking,
5
+ * and secure credential storage via DSH credentials service.
6
+ */
7
+
8
+ import {
9
+ CLINE_MODELS,
10
+ DEFAULT_MODEL_ID,
11
+ PROVIDER_ID,
12
+ PROVIDER_DISPLAY_NAME,
13
+ getAllModels,
14
+ } from './models.js'
15
+
16
+ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
17
+ export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
18
+ export const DEFAULT_TIMEOUT_MS = 15000
19
+ export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
20
+
21
+ export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
22
+
23
+ /**
24
+ * Resolve or fallback credential reference descriptor.
25
+ */
26
+ export async function toCredentialRef(name) {
27
+ try {
28
+ const mod = await import('@deepseek-ai/dsh-credentials')
29
+ if (typeof mod.credentialRef === 'function') {
30
+ return mod.credentialRef(name)
31
+ }
32
+ } catch {
33
+ /* fallback when executed in standalone unit tests outside DSH bundle */
34
+ }
35
+ return typeof name === 'object' && name !== null ? name : { type: 'env', name: String(name || '') }
36
+ }
37
+
38
+ /**
39
+ * Normalize base URL ensuring clean format without trailing slashes.
40
+ */
41
+ export function normalizeBaseUrl(raw) {
42
+ let s = String(raw || '').trim().replace(/\/+$/, '')
43
+ if (!s) return DEFAULT_BASE_URL
44
+ s = s.replace(/\/chat\/completions$/i, '')
45
+ if (s === 'https://api.cline.bot' || s === 'http://api.cline.bot') {
46
+ s += '/api/v1'
47
+ }
48
+ return s
49
+ }
50
+
51
+ /**
52
+ * Resolve API key from environment variables.
53
+ */
54
+ export function resolveApiKey(apiKeyEnv, env = process.env) {
55
+ const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
56
+ return {
57
+ envName: name,
58
+ value: String(env[name] || ''),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Save API key directly into DSH credentials service (~/.dsh/.credentials.yaml).
64
+ */
65
+ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
66
+ const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
67
+ const value = String(apiKey || '').trim()
68
+
69
+ if (!value) {
70
+ throw new Error('API key cannot be empty')
71
+ }
72
+
73
+ const credentials = ctx?.credentials || ctx?.get?.('credentials')
74
+ if (!credentials || typeof credentials.set !== 'function') {
75
+ throw new Error('DSH credentials service is unavailable in this runtime profile')
76
+ }
77
+
78
+ const ref = await toCredentialRef(name)
79
+ await credentials.set(ref, value)
80
+ return { ok: true, envName: name }
81
+ }
82
+
83
+ function abortAfter(ms) {
84
+ const ac = new AbortController()
85
+ const timer = setTimeout(() => ac.abort(), Math.max(1, Number(ms) || DEFAULT_TIMEOUT_MS))
86
+ if (typeof timer.unref === 'function') timer.unref()
87
+ return { signal: ac.signal, cancel: () => clearTimeout(timer) }
88
+ }
89
+
90
+ // In-memory cache for quota queries to avoid hammering the ClinePass endpoint
91
+ const usageCache = new Map()
92
+ export function clearUsageCache() {
93
+ usageCache.clear()
94
+ }
95
+
96
+ /**
97
+ * Fetch official ClinePass rate limits and account quota.
98
+ * Endpoints:
99
+ * - GET /users/me/plan/usage-limits (5-hour, weekly, monthly rolling limits)
100
+ * - GET /users/me (account metadata)
101
+ */
102
+ export async function fetchUsageLimits(baseUrl, apiKey, {
103
+ timeoutMs = DEFAULT_TIMEOUT_MS,
104
+ fetchImpl = fetch,
105
+ bypassCache = false,
106
+ } = {}) {
107
+ const base = normalizeBaseUrl(baseUrl)
108
+ if (!apiKey) {
109
+ return { ok: false, error: 'API key is missing' }
110
+ }
111
+
112
+ const cacheKey = `cline:usage:${apiKey.slice(-8)}`
113
+ const now = Date.now()
114
+
115
+ if (!bypassCache && usageCache.has(cacheKey)) {
116
+ const cached = usageCache.get(cacheKey)
117
+ if (cached.expiresAt > now) {
118
+ return cached.data
119
+ }
120
+ }
121
+
122
+ const { signal, cancel } = abortAfter(timeoutMs)
123
+ try {
124
+ const headers = {
125
+ Authorization: `Bearer ${apiKey}`,
126
+ Accept: 'application/json',
127
+ }
128
+
129
+ // 1. Fetch usage limits
130
+ const limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
131
+ method: 'GET',
132
+ headers,
133
+ signal,
134
+ })
135
+
136
+ if (!limitsRes.ok) {
137
+ const errText = await limitsRes.text().catch(() => '')
138
+ return {
139
+ ok: false,
140
+ status: limitsRes.status,
141
+ error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
142
+ }
143
+ }
144
+
145
+ const limitsData = await limitsRes.json().catch(() => ({}))
146
+ const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
147
+
148
+ const parseWindow = (type) => {
149
+ const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
150
+ if (!found) return null
151
+ const percentUsed = typeof found.percentUsed === 'number'
152
+ ? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
153
+ : 0
154
+ const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
155
+ return {
156
+ type,
157
+ percentUsed,
158
+ remainingPercent,
159
+ resetsAt: found.resetsAt || null,
160
+ }
161
+ }
162
+
163
+ const fiveHour = parseWindow('5-hour')
164
+ const weekly = parseWindow('weekly')
165
+ const monthly = parseWindow('monthly')
166
+
167
+ // 2. Fetch user metadata (optional, best-effort)
168
+ let userEmail = null
169
+ let createdAt = null
170
+ try {
171
+ const meRes = await fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal })
172
+ if (meRes.ok) {
173
+ const meData = await meRes.json().catch(() => ({}))
174
+ const me = meData?.data || meData?.user || meData
175
+ userEmail = me?.email || null
176
+ createdAt = me?.createdAt || null
177
+ }
178
+ } catch {
179
+ /* ignore user metadata failure */
180
+ }
181
+
182
+ const result = {
183
+ ok: true,
184
+ plan: 'ClinePass ($9.99/mo)',
185
+ user: {
186
+ email: userEmail,
187
+ createdAt,
188
+ },
189
+ windows: {
190
+ fiveHour: fiveHour || { type: '5-hour', percentUsed: 0, remainingPercent: 100, resetsAt: null },
191
+ weekly: weekly || { type: 'weekly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
192
+ monthly: monthly || { type: 'monthly', percentUsed: 0, remainingPercent: 100, resetsAt: null },
193
+ },
194
+ checkedAt: now,
195
+ }
196
+
197
+ // Cache for 60 seconds
198
+ usageCache.set(cacheKey, { expiresAt: now + 60000, data: result })
199
+ return result
200
+ } catch (err) {
201
+ return {
202
+ ok: false,
203
+ error: String(err?.message || err),
204
+ }
205
+ } finally {
206
+ cancel()
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Quick network probe to verify server availability.
212
+ */
213
+ export async function probeHealth(baseUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
214
+ const root = normalizeBaseUrl(baseUrl)
215
+ const { signal, cancel } = abortAfter(timeoutMs)
216
+ const start = Date.now()
217
+ try {
218
+ const res = await fetchImpl(root, { method: 'GET', signal }).catch(async () => {
219
+ return await fetchImpl(root, { method: 'HEAD', signal })
220
+ })
221
+ const latencyMs = Date.now() - start
222
+ const reachable = res.status > 0 && res.status < 500
223
+ return {
224
+ ok: reachable,
225
+ status: res.status,
226
+ latencyMs,
227
+ error: reachable ? null : `HTTP status ${res.status}`,
228
+ }
229
+ } catch (err) {
230
+ const latencyMs = Date.now() - start
231
+ return {
232
+ ok: false,
233
+ latencyMs,
234
+ error: String(err?.message || err),
235
+ }
236
+ } finally {
237
+ cancel()
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Non-streaming lightweight chat completion to verify credentials and endpoint latency.
243
+ */
244
+ export async function smokeChat(baseUrl, apiKey, {
245
+ model = DEFAULT_MODEL_ID,
246
+ timeoutMs = DEFAULT_SMOKE_TIMEOUT_MS,
247
+ fetchImpl = fetch,
248
+ } = {}) {
249
+ const base = normalizeBaseUrl(baseUrl)
250
+ if (!apiKey) {
251
+ return { ok: false, error: 'Missing API key. Set credential or environment variable.' }
252
+ }
253
+
254
+ const { signal, cancel } = abortAfter(timeoutMs)
255
+ const start = Date.now()
256
+ try {
257
+ const res = await fetchImpl(`${base}/chat/completions`, {
258
+ method: 'POST',
259
+ headers: {
260
+ Authorization: `Bearer ${apiKey}`,
261
+ 'Content-Type': 'application/json',
262
+ },
263
+ body: JSON.stringify({
264
+ model: model || DEFAULT_MODEL_ID,
265
+ messages: [{ role: 'user', content: 'Say pong' }],
266
+ max_tokens: 150,
267
+ stream: false,
268
+ }),
269
+ signal,
270
+ })
271
+
272
+ const latencyMs = Date.now() - start
273
+ const data = await res.json().catch(() => ({}))
274
+
275
+ if (!res.ok) {
276
+ const errorMsg = data?.error?.message || (typeof data?.error === 'string' ? data.error : null) || data?.message || `HTTP ${res.status}`
277
+ return {
278
+ ok: false,
279
+ status: res.status,
280
+ latencyMs,
281
+ error: errorMsg,
282
+ }
283
+ }
284
+
285
+ const payload = data?.data && typeof data.data === 'object' ? data.data : data
286
+ const content = payload?.choices?.[0]?.message?.content || payload?.choices?.[0]?.message?.reasoning
287
+ return {
288
+ ok: true,
289
+ status: res.status,
290
+ latencyMs,
291
+ model: payload?.model || model,
292
+ preview: typeof content === 'string' ? content.trim().slice(0, 150) : 'OK',
293
+ }
294
+ } catch (err) {
295
+ const latencyMs = Date.now() - start
296
+ return {
297
+ ok: false,
298
+ latencyMs,
299
+ error: String(err?.message || err),
300
+ }
301
+ } finally {
302
+ cancel()
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Shape for llm-pi-ai.providers.clinebot (openai-completions).
308
+ */
309
+ export function buildPiAiProvider({
310
+ baseUrl = DEFAULT_BASE_URL,
311
+ apiKeyEnv = DEFAULT_API_KEY_ENV,
312
+ models = [],
313
+ customModels = [],
314
+ displayName = PROVIDER_DISPLAY_NAME,
315
+ } = {}) {
316
+ const allAvailable = getAllModels(customModels)
317
+ const modelList = (Array.isArray(models) && models.length ? models : allAvailable).map((m) => {
318
+ let item
319
+ if (typeof m === 'string') {
320
+ item = allAvailable.find((x) => x.id === m) || { id: m, name: m }
321
+ } else {
322
+ item = m
323
+ }
324
+ const hasImage = item.input?.includes('image') || item.input?.includes('vision')
325
+ return {
326
+ id: item.id,
327
+ name: item.name || item.id,
328
+ contextWindow: Number(item.contextLength || item.contextWindow) || 200000,
329
+ maxTokens: Number(item.maxTokens) || 8192,
330
+ input: hasImage ? ['text', 'image'] : ['text'],
331
+ provider: PROVIDER_ID,
332
+ }
333
+ })
334
+
335
+ return {
336
+ displayName,
337
+ api: 'openai-completions',
338
+ baseURL: normalizeBaseUrl(baseUrl),
339
+ apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
340
+ models: modelList,
341
+ }
342
+ }
package/lib/http.js ADDED
@@ -0,0 +1,34 @@
1
+ export function writeJson(res, code, body) {
2
+ try {
3
+ res.writeHead(code, {
4
+ 'Content-Type': 'application/json; charset=utf-8',
5
+ 'Cache-Control': 'no-store',
6
+ })
7
+ res.end(JSON.stringify(body))
8
+ } catch {
9
+ /* socket already closed */
10
+ }
11
+ }
12
+
13
+ export function readBody(req, maxBytes = 256 * 1024) {
14
+ return new Promise((resolve, reject) => {
15
+ const chunks = []
16
+ let size = 0
17
+ req.on('data', (c) => {
18
+ size += c.length
19
+ if (size > maxBytes) {
20
+ reject(new Error('body too large'))
21
+ req.destroy()
22
+ return
23
+ }
24
+ chunks.push(c)
25
+ })
26
+ req.on('end', () => resolve(Buffer.concat(chunks)))
27
+ req.on('error', reject)
28
+ })
29
+ }
30
+
31
+ /** Reject cross-site writes. LAN / reverse-proxy UIs are allowed. */
32
+ export function isTrustedSettingsRequest(request) {
33
+ return request.headers['sec-fetch-site'] !== 'cross-site'
34
+ }