@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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/README.ru.md +131 -0
- package/README.zh.md +68 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +895 -0
- package/lib/cline-client.js +342 -0
- package/lib/http.js +34 -0
- package/lib/index.js +573 -0
- package/lib/models.js +210 -0
- package/package.json +68 -0
|
@@ -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
|
+
}
|