dsh-cline-pass 0.1.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/lib/index.js ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * dsh-cline-pass — a self-contained Cline Pass provider for the DeepSeek
3
+ * Harness. It registers on the host plane:
4
+ *
5
+ * 1. One LLM provider route (default `cline-pass`), served by
6
+ * {@link ClinePassAdapter}.
7
+ * 2. A configurable-provider directory entry for that route.
8
+ * 3. The `cline_pass_*` tools, and the setup panel's host half.
9
+ *
10
+ * Configuration lives in the `cline-pass` settings section, so it survives
11
+ * restarts and reloads live. API keys never go there: accounts name a
12
+ * credential reference resolved per request through the credential seam.
13
+ *
14
+ * Routing behavior reimplements the MIT-licensed cline-pass-switcher findings.
15
+ *
16
+ * @module dsh-cline-pass
17
+ */
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
+ import { assertUsableApiKey, errorChain, LlmError } from '@deepseek-ai/dsh-llm'
21
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
22
+ import {
23
+ ClinePassAdapter,
24
+ DEFAULT_CONTEXT_WINDOW,
25
+ DEFAULT_MAX_TOKENS,
26
+ DEFAULT_REQUEST_IMAGE_POLICY,
27
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
28
+ } from './adapter.js'
29
+ import { catalogEntry, MODEL_CATALOG, REASONING_EFFORTS } from './catalog.js'
30
+ import { createEngine } from './engine.js'
31
+ import { createPanel, PANEL_PATH, registerPanel } from './panel.js'
32
+ import { createStore } from './store.js'
33
+ import { registerTools } from './tools.js'
34
+
35
+ export { ClinePassAdapter, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_REQUEST_IMAGE_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS }
36
+ export { collectImageRefs, prepareRequestImages } from './adapter.js'
37
+ export { MODEL_CATALOG, REASONING_EFFORTS } from './catalog.js'
38
+ export { createEngine } from './engine.js'
39
+ export { createPanel, PANEL_PATH, registerPanel } from './panel.js'
40
+ export { createStore } from './store.js'
41
+ export { buildAttempts, injectPrefs, parseRouting } from './protocol.js'
42
+
43
+ /** Stable Loader identity. */
44
+ export const name = 'cline-pass'
45
+
46
+ /** The LLM seam and the host tool registry. */
47
+ export const inject = ['llm', 'tools']
48
+
49
+ /** The settings namespace this plugin owns. */
50
+ export const DEFAULT_SETTINGS_NS = 'cline-pass'
51
+
52
+ /** The provider route registered when configuration names none. */
53
+ export const DEFAULT_PROVIDER = 'cline-pass'
54
+
55
+ /** The credential name used when no account declares one. */
56
+ export const DEFAULT_API_KEY_ENV = 'CLINE_PASS_API_KEY'
57
+
58
+ /** Cline Pass subscription models current at release time; refreshed on demand. */
59
+ export const DEFAULT_MODELS = [
60
+ 'cline-pass/glm-5.3-flash',
61
+ 'cline-pass/kimi-k3',
62
+ 'cline-pass/deepseek-v4-flash',
63
+ 'cline-pass/deepseek-v4.1-flash',
64
+ 'cline-pass/qwen3.8-max',
65
+ 'cline-pass/minimax-m3',
66
+ 'cline-pass/glm-5.3',
67
+ 'cline-pass/glm-5.2',
68
+ 'cline-pass/deepseek-v4-pro',
69
+ 'cline-pass/mimo-v2.5-pro',
70
+ 'cline-pass/mimo-v2.5',
71
+ 'cline-pass/kimi-k2.6',
72
+ 'cline-pass/qwen3.7-plus',
73
+ 'cline-pass/kimi-k2.7-code',
74
+ 'cline-pass/qwen3.7-max',
75
+ ]
76
+
77
+ /** One Cline Pass account: a credential reference plus routing participation. */
78
+ export const AccountProfile = z.object({
79
+ displayName: z.string().default(''),
80
+ apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
81
+ enabled: z.boolean().default(true),
82
+ baseURL: z.string().default(''),
83
+ })
84
+
85
+ /** One model's catalog override, merged over the published metadata. */
86
+ export const ModelProfile = z.object({
87
+ name: z.string().default(''),
88
+ contextWindow: z.number().default(0),
89
+ maxTokens: z.number().default(0),
90
+ reasoning: z.union([z.boolean(), z.const(undefined)]).default(undefined),
91
+ input: z.array(z.string()).default([]),
92
+ })
93
+
94
+ /** One model's upstream pin. */
95
+ export const PinProfile = z.object({
96
+ upstreams: z.array(z.string()).default([]),
97
+ exclude: z.array(z.string()).default([]),
98
+ pinMode: z.string().default('strict'),
99
+ sort: z.string().default(''),
100
+ })
101
+
102
+ /** Validated plugin configuration (this plugin's settings section). */
103
+ export const Config = z.object({
104
+ provider: z.string().default(DEFAULT_PROVIDER),
105
+ displayName: z.string().default('Cline Pass'),
106
+ baseURL: z.string().default('https://api.cline.bot/api/v1'),
107
+ apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
108
+ accounts: z.dict(AccountProfile).default({}),
109
+ accountMode: z.string().default('single'),
110
+ activeAccount: z.string().default(''),
111
+ knownModels: z.array(z.string()).default(DEFAULT_MODELS),
112
+ models: z.dict(ModelProfile).default({}),
113
+ perModel: z.dict(PinProfile).default({}),
114
+ reasoningModels: z.boolean().default(true),
115
+ defaultContextWindow: z.number().default(DEFAULT_CONTEXT_WINDOW),
116
+ defaultMaxTokens: z.number().default(DEFAULT_MAX_TOKENS),
117
+ streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
118
+ exposeCatalog: z.boolean().default(false),
119
+ historyLimit: z.number().default(100),
120
+ })
121
+
122
+ /** Redact a secret, keeping just enough to recognize it. */
123
+ function maskKey(value) {
124
+ const key = String(value ?? '')
125
+ if (key.length === 0) return ''
126
+ if (key.length <= 10) return `${key.slice(0, 2)}…${key.slice(-2)}`
127
+ return `${key.slice(0, 6)}…${key.slice(-4)}`
128
+ }
129
+
130
+ /**
131
+ * Register the Cline Pass provider on the harness LLM seam, expose its
132
+ * management tools, and publish its setup panel.
133
+ *
134
+ * @param ctx - host plugin context.
135
+ * @param config - validated plugin configuration.
136
+ */
137
+ export function apply(ctx, config) {
138
+ const store = createStore({ historyLimit: config.historyLimit })
139
+ let liveConfig = config
140
+ let current = () => liveConfig
141
+ const live = () => current()
142
+
143
+ // ── account pool ──────────────────────────────────────────────────────────
144
+
145
+ let roundRobin = 0
146
+
147
+ /**
148
+ * Resolve the configured accounts.
149
+ *
150
+ * An empty `accounts` dictionary means one implicit account on the top-level
151
+ * fields, so a fresh install works as soon as `CLINE_PASS_API_KEY` exists.
152
+ */
153
+ const accountProfiles = () => {
154
+ const cfg = live()
155
+ const declared = Object.entries(cfg.accounts ?? {})
156
+ if (declared.length === 0) {
157
+ return [['default', {
158
+ displayName: cfg.displayName,
159
+ apiKeyEnv: credentialRef(cfg.apiKeyEnv),
160
+ enabled: true,
161
+ baseURL: cfg.baseURL,
162
+ }]]
163
+ }
164
+ return declared.map(([key, profile]) => [key, {
165
+ displayName: String(profile?.displayName ?? '') || key,
166
+ apiKeyEnv: credentialRef(profile?.apiKeyEnv || cfg.apiKeyEnv),
167
+ enabled: profile?.enabled !== false,
168
+ baseURL: String(profile?.baseURL ?? '') || cfg.baseURL,
169
+ }])
170
+ }
171
+
172
+ /**
173
+ * Resolve one credential reference through the credential seam, falling back
174
+ * to the launching environment when no credentials service is composed.
175
+ * @returns the key value, or undefined when nothing is stored.
176
+ */
177
+ const resolveKey = async (ref) => {
178
+ const credentials = ctx.get('credentials')
179
+ if (credentials !== undefined) {
180
+ const hit = await credentials.resolve(ref)
181
+ const value = hit?.value
182
+ return value !== undefined && String(value).length > 0 ? String(value) : undefined
183
+ }
184
+ const ambient = launchEnvironmentOf(ctx).get(ref)
185
+ return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
186
+ }
187
+
188
+ /** Pick the account for the next request and resolve its key. */
189
+ const resolveAccount = async () => {
190
+ const cfg = live()
191
+ const enabled = accountProfiles().filter(([, profile]) => profile.enabled)
192
+ if (enabled.length === 0) {
193
+ throw new LlmError('cline-pass: every configured account is disabled; enable one or add another', 'MISSING_CREDENTIAL')
194
+ }
195
+ let picked
196
+ if (cfg.accountMode === 'roundrobin' && enabled.length > 1) {
197
+ picked = enabled[roundRobin % enabled.length]
198
+ roundRobin = (roundRobin + 1) % 1000000000
199
+ } else {
200
+ picked = enabled.find(([key]) => key === cfg.activeAccount) ?? enabled[0]
201
+ }
202
+ const [key, profile] = picked
203
+ const value = await resolveKey(profile.apiKeyEnv)
204
+ if (value === undefined) {
205
+ throw new LlmError(
206
+ `cline-pass: no API key for account "${key}"; store it at ${profile.apiKeyEnv} (cline_pass_accounts action="add" name=${key} key=… writes it, or use the dsh Models page), or export ${profile.apiKeyEnv} in the launching environment`,
207
+ 'MISSING_CREDENTIAL',
208
+ )
209
+ }
210
+ return { name: key, key: assertUsableApiKey(value, 'cline-pass', profile.apiKeyEnv), baseURL: profile.baseURL }
211
+ }
212
+
213
+ // ── connection facts ──────────────────────────────────────────────────────
214
+
215
+ /** The model ids the route advertises: configured, plus the catalog when asked. */
216
+ const modelIds = () => {
217
+ const cfg = live()
218
+ const ids = (cfg.knownModels ?? []).map(String)
219
+ if (cfg.exposeCatalog === true) {
220
+ for (const id of store.catalog().ids ?? []) if (!ids.includes(id)) ids.push(String(id))
221
+ }
222
+ return ids
223
+ }
224
+
225
+ const connection = () => {
226
+ const cfg = live()
227
+ return {
228
+ baseURL: cfg.baseURL,
229
+ displayName: cfg.displayName,
230
+ models: modelIds().map((id) => {
231
+ const entry = catalogEntry(id) ?? {}
232
+ const override = cfg.models?.[id] ?? {}
233
+ return {
234
+ id,
235
+ name: override.name || entry.name || id.replace(/^cline-pass\//, ''),
236
+ contextWindow: override.contextWindow || entry.contextWindow,
237
+ maxTokens: override.maxTokens || entry.maxTokens,
238
+ reasoning: override.reasoning ?? entry.reasoning,
239
+ input: override.input?.length ? override.input : entry.input,
240
+ }
241
+ }),
242
+ defaultContextWindow: cfg.defaultContextWindow,
243
+ maxTokens: cfg.defaultMaxTokens,
244
+ reasoningModels: cfg.reasoningModels,
245
+ streamIdleTimeoutMs: cfg.streamIdleTimeoutMs,
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Best context-window guess for one model: the largest window OpenRouter
251
+ * reports for its endpoints, when a probe has seen them.
252
+ */
253
+ const discoveredContext = (model) => {
254
+ const detail = store.metaOf(model)?.upstreamDetail ?? {}
255
+ let max = 0
256
+ for (const entry of Object.values(detail)) max = Math.max(max, Number(entry?.context ?? 0))
257
+ return max > 0 ? max : undefined
258
+ }
259
+
260
+ // ── engine + adapter ──────────────────────────────────────────────────────
261
+
262
+ const engine = createEngine({
263
+ resolveAccount,
264
+ store,
265
+ logger: ctx.logger,
266
+ attemptTimeoutMs: 180000,
267
+ probeTimeoutMs: 60000,
268
+ })
269
+ engine.setPinReader((model) => live().perModel?.[model] ?? {})
270
+
271
+ const adapter = new ClinePassAdapter({
272
+ connection,
273
+ modelMeta: (model) => store.metaOf(model),
274
+ pin: (model) => live().perModel?.[model] ?? {},
275
+ resolveAccount,
276
+ discoveredContext,
277
+ // Images reach an adapter as durable attachment references, never as bytes,
278
+ // so the adapter resolves them through the attachment service per request.
279
+ // Resolved lazily: a text-only call must not require that service.
280
+ resolveAttachments: () => ctx.get('attachments'),
281
+ record: (model, info) => store.record({ model, ...info }),
282
+ learnUpstream: (model, upstream, status, note, ms) => store.learnUpstream(model, upstream, status, note, ms),
283
+ })
284
+
285
+ const provider = String(config.provider ?? '').trim() || DEFAULT_PROVIDER
286
+ if (config.provider !== provider) {
287
+ throw new LlmError('cline-pass: `provider` must be a non-empty route name', 'INVALID_ADAPTER')
288
+ }
289
+ ctx.llm.registerAdapter([provider], adapter)
290
+
291
+ // The directory entry is what makes the web Models page treat this settings
292
+ // namespace as the configuration of this route; a conflict here is cosmetic
293
+ // and must not take the route down with it.
294
+ try {
295
+ ctx.llm.registerConfigurableProviders([{
296
+ provider,
297
+ displayName: config.displayName,
298
+ settingsNs: DEFAULT_SETTINGS_NS,
299
+ settingsPath: [],
300
+ }])
301
+ } catch (error) {
302
+ ctx.logger?.warn('[cline-pass] configurable-provider directory entry refused: %s', errorChain(error))
303
+ }
304
+
305
+ // ── configuration surface ─────────────────────────────────────────────────
306
+
307
+ /**
308
+ * Persist one shallow patch into this plugin's settings section.
309
+ *
310
+ * Nested dictionaries (`accounts`, `perModel`) are always written whole by
311
+ * the caller, so the section's merge semantics never half-apply a change.
312
+ * Without a settings service the patch is applied in memory, which keeps the
313
+ * session working but does not survive a restart.
314
+ */
315
+ const updateConfig = async (patch) => {
316
+ const settings = ctx.get('settings')
317
+ if (settings === undefined) {
318
+ liveConfig = { ...liveConfig, ...patch }
319
+ return
320
+ }
321
+ await settings.update(DEFAULT_SETTINGS_NS, patch)
322
+ }
323
+
324
+ ctx.inject(['settings'], (settingsCtx) => {
325
+ settingsCtx.settings.installSection(ctx, DEFAULT_SETTINGS_NS, Config, config, {
326
+ setSource: (source) => {
327
+ current = source
328
+ },
329
+ // Every consumer reads the live config through a thunk, so a settings
330
+ // change reaches the next request with nothing to re-register.
331
+ onChange: () => {},
332
+ })
333
+ })
334
+
335
+ // ── catalog ───────────────────────────────────────────────────────────────
336
+
337
+ /** Rescan the official subscription list and adopt models that are new. */
338
+ const refreshCatalog = async () => {
339
+ const scan = await engine.officialModels({})
340
+ const cfg = live()
341
+ const currentModels = (cfg.knownModels ?? []).map(String)
342
+ const added = scan.models.filter((id) => !currentModels.includes(id))
343
+ if (added.length > 0) await updateConfig({ knownModels: [...currentModels, ...added] })
344
+ store.setCatalog({
345
+ ids: [...new Set([...scan.models, ...(cfg.exposeCatalog === true ? await engine.gatewayCatalog({}).catch(() => []) : [])])],
346
+ sources: scan.sources,
347
+ })
348
+ return { added, models: scan.models, sources: scan.sources }
349
+ }
350
+
351
+ // ── tools ─────────────────────────────────────────────────────────────────
352
+
353
+ // One control surface serves both faces of the configuration: the
354
+ // `cline_pass_*` tools an agent calls, and the browser setup panel. Sharing
355
+ // it is what keeps the two from ever disagreeing about what is configured.
356
+ const control = {
357
+ providerName: provider,
358
+ displayName: config.displayName,
359
+ settingsAvailable: () => ctx.get('settings') !== undefined,
360
+ routeRegistered: () => true,
361
+ readConfig: () => live(),
362
+ updateConfig,
363
+ accounts: () => accountProfiles().map(([key, profile]) => ({
364
+ key,
365
+ displayName: profile.displayName,
366
+ apiKeyEnv: String(profile.apiKeyEnv),
367
+ enabled: profile.enabled,
368
+ baseURL: profile.baseURL,
369
+ })),
370
+ accountsWithKeys: async () => await Promise.all(accountProfiles().map(async ([key, profile]) => {
371
+ const value = await resolveKey(profile.apiKeyEnv).catch(() => undefined)
372
+ return {
373
+ key,
374
+ displayName: profile.displayName,
375
+ apiKeyEnv: String(profile.apiKeyEnv),
376
+ enabled: profile.enabled,
377
+ keyConfigured: value !== undefined,
378
+ keyHint: maskKey(value),
379
+ }
380
+ })),
381
+ readCredential: async (ref) => (await resolveKey(credentialRef(ref))) ?? '',
382
+ setCredential: async (ref, value) => {
383
+ const credentials = ctx.get('credentials')
384
+ if (credentials === undefined) {
385
+ throw new Error('the dsh credentials service is unavailable, so the key cannot be stored; set the environment variable instead')
386
+ }
387
+ await credentials.set(credentialRef(ref), String(value))
388
+ },
389
+ refreshCatalog,
390
+ }
391
+
392
+ registerTools(ctx, { store, engine, control })
393
+
394
+ // The same surface as an ordinary browser page, for users who would rather
395
+ // click than compose a tool call. Absent in a headless composition.
396
+ try {
397
+ registerPanel(ctx, { control, engine, store, logger: ctx.logger })
398
+ } catch (error) {
399
+ ctx.logger?.warn('[cline-pass] setup panel unavailable (the tools still work): %s', errorChain(error))
400
+ }
401
+
402
+ // Keep the provider directory honest without adopting anything: an explicit
403
+ // `cline_pass_models refresh` is what changes the model list.
404
+ void refreshCatalog().catch((error) => {
405
+ ctx.logger?.warn('[cline-pass] initial catalog scan failed (models stay on the configured list): %s', errorChain(error))
406
+ })
407
+
408
+ ctx.logger?.info('[cline-pass] provider route "%s" registered with %d model(s)', provider, modelIds().length)
409
+ }