dsh-connect-qoder 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/README.md +100 -0
- package/cordis.patch.yml +6 -0
- package/lib/adapter.js +445 -0
- package/lib/client.js +992 -0
- package/lib/credentials.js +324 -0
- package/lib/errors.js +37 -0
- package/lib/index.js +691 -0
- package/lib/shim.js +378 -0
- package/lib/upstream.js +1232 -0
- package/package.json +71 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Connect Qoder — bring locally signed-in Qoder models into DeepSeek
|
|
3
|
+
* Harness.
|
|
4
|
+
*
|
|
5
|
+
* The Qoder desktop apps (Qoder CN and the international Qoder) already hold a
|
|
6
|
+
* valid sign-in on this machine. This bundle reads that sign-in, registers one
|
|
7
|
+
* DSH provider per region, and routes each region's traffic through a private
|
|
8
|
+
* loopback shim that speaks OpenAI to pi-ai and COSY-signed Qoder on the way
|
|
9
|
+
* out.
|
|
10
|
+
*
|
|
11
|
+
* Both regions register unconditionally and independently: whichever apps are
|
|
12
|
+
* signed in produce a visible model group, and a region with no credential
|
|
13
|
+
* simply contributes no models. Nothing here starts an OAuth flow, and nothing
|
|
14
|
+
* writes to the Qoder apps' own files.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-connect-qoder
|
|
17
|
+
*/
|
|
18
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import z from '@deepseek-ai/schemastery'
|
|
22
|
+
import { createQoderAdapter, offPeakActive, offPeakRemaining, rateNow } from './adapter.js'
|
|
23
|
+
import { createQoderShim } from './shim.js'
|
|
24
|
+
import { REGIONS, loadCredential, loadEnvCredential } from './credentials.js'
|
|
25
|
+
import { exchangePat, fetchModels, fetchUsage } from './upstream.js'
|
|
26
|
+
|
|
27
|
+
/** Loader row id; also the plugin's identity in the composition. */
|
|
28
|
+
export const name = 'llm-qoder'
|
|
29
|
+
|
|
30
|
+
/** The model registry that must exist before a provider can register. */
|
|
31
|
+
export const inject = ['llm']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Settings namespace owning this plugin's section.
|
|
35
|
+
*
|
|
36
|
+
* A namespace only becomes configurable once a section is installed into it —
|
|
37
|
+
* `registerConfigurableProviders` merely *addresses* the namespace. Without the
|
|
38
|
+
* section there is no schema, so no configuration surface can render a control
|
|
39
|
+
* for this route at all.
|
|
40
|
+
*/
|
|
41
|
+
export const QODER_SETTINGS_NS = 'dsh-connect-qoder'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Prefer the largest context window the catalog declares for a model.
|
|
45
|
+
*
|
|
46
|
+
* Qoder's catalog offers 200K/400K/1M for most models and flags one as its own
|
|
47
|
+
* default. This switch decides whether this plugin advertises the largest
|
|
48
|
+
* offered window or the one Qoder itself starts on.
|
|
49
|
+
*/
|
|
50
|
+
const USE_MAXIMUM_CONTEXT_WINDOW_FIELD = z
|
|
51
|
+
.boolean()
|
|
52
|
+
.default(false)
|
|
53
|
+
.description('Advertise each model\'s largest declared context window instead of Qoder\'s own default window')
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Per-model image-input opt-in, keyed by user-facing model id.
|
|
57
|
+
*
|
|
58
|
+
* Qoder's catalog advertises `is_vl` per model and this plugin maps it straight
|
|
59
|
+
* onto the pi-ai `input` list, so a vision model arrives already declaring
|
|
60
|
+
* image input. The user still owns the final say: an entry here overrides the
|
|
61
|
+
* catalog for one model, which is what lets a model the catalog marks text-only
|
|
62
|
+
* (or one the endpoint rejects images for) be turned off — and what lets a
|
|
63
|
+
* mislabelled model be turned on. Absent means "follow `is_vl`".
|
|
64
|
+
*/
|
|
65
|
+
const IMAGE_MODES = ['auto', 'on', 'off']
|
|
66
|
+
|
|
67
|
+
/** One model's image preference. */
|
|
68
|
+
const IMAGE_OVERRIDES_FIELD = z
|
|
69
|
+
.dict(z.union(IMAGE_MODES))
|
|
70
|
+
.default({})
|
|
71
|
+
.description('Per-model image input: "auto" follows the catalog, "on"/"off" force it')
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which models the picker offers, per region.
|
|
75
|
+
*
|
|
76
|
+
* An **empty list means "no filter"**, not "nothing": that is the same
|
|
77
|
+
* convention the WorkBuddy bundle uses, and it is what keeps a fresh install
|
|
78
|
+
* working — a new user has saved nothing yet, and must still see every model.
|
|
79
|
+
* Once a list is non-empty it becomes an allow-list for that region.
|
|
80
|
+
*
|
|
81
|
+
* Keyed by region id (`qoder-cn`, `qoder`) so the two editions can be curated
|
|
82
|
+
* independently; the CN and global rosters share no model ids.
|
|
83
|
+
*/
|
|
84
|
+
const ENABLED_MODEL_IDS_FIELD = z
|
|
85
|
+
.dict(z.array(z.string()))
|
|
86
|
+
.default({})
|
|
87
|
+
.description('Per-region allow-list of model ids; an empty list shows every model')
|
|
88
|
+
|
|
89
|
+
/** The plugin's whole configuration schema. */
|
|
90
|
+
export const Config = z.object({
|
|
91
|
+
useMaximumContextWindow: USE_MAXIMUM_CONTEXT_WINDOW_FIELD,
|
|
92
|
+
imageOverrides: IMAGE_OVERRIDES_FIELD,
|
|
93
|
+
enabledModelIds: ENABLED_MODEL_IDS_FIELD,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/** The section this plugin publishes for its settings namespace. */
|
|
97
|
+
const QODER_SECTION = z.object({
|
|
98
|
+
useMaximumContextWindow: USE_MAXIMUM_CONTEXT_WINDOW_FIELD,
|
|
99
|
+
imageOverrides: IMAGE_OVERRIDES_FIELD,
|
|
100
|
+
enabledModelIds: ENABLED_MODEL_IDS_FIELD,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** Plugin-owned read-only route the settings card reads its model rows from. */
|
|
104
|
+
const QODER_MODELS_PATH = '/plugins/dsh-connect-qoder/models'
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Plugin-owned read-only route the card reads its usage panel from.
|
|
108
|
+
*
|
|
109
|
+
* Kept separate from the model route so the panel can be refreshed on its own:
|
|
110
|
+
* quota moves with every turn, while the catalog changes rarely.
|
|
111
|
+
*/
|
|
112
|
+
const QODER_USAGE_PATH = '/plugins/dsh-connect-qoder/usage'
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long a fetched usage reading stays fresh.
|
|
116
|
+
*
|
|
117
|
+
* Quota moves only when a turn runs, so a short cache keeps the panel honest
|
|
118
|
+
* without turning every card render into an upstream round trip. The refresh
|
|
119
|
+
* button bypasses it.
|
|
120
|
+
*/
|
|
121
|
+
const USAGE_TTL_MS = 20 * 1000
|
|
122
|
+
|
|
123
|
+
/** Answer one card request. */
|
|
124
|
+
function sendJson(res, status, value) {
|
|
125
|
+
const payload = JSON.stringify(value)
|
|
126
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) })
|
|
127
|
+
res.end(payload)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether a card request came from this machine.
|
|
132
|
+
*
|
|
133
|
+
* The route exposes model metadata, not credentials, but it stays loopback-only
|
|
134
|
+
* anyway: it is an internal read path for a browser page this host itself
|
|
135
|
+
* served, and a missing Origin (a same-origin GET) is the normal case.
|
|
136
|
+
*/
|
|
137
|
+
function loopbackRequest(req) {
|
|
138
|
+
const origin = req.headers.origin
|
|
139
|
+
if (origin === undefined) return true
|
|
140
|
+
if (typeof origin !== 'string') return false
|
|
141
|
+
try {
|
|
142
|
+
const host = new URL(origin).hostname
|
|
143
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
144
|
+
} catch {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** How long a fetched catalog stays fresh before it is refetched. */
|
|
150
|
+
const CATALOG_TTL_MS = 30 * 60 * 1000
|
|
151
|
+
|
|
152
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
153
|
+
const CATALOG_FORMAT_VERSION = 1
|
|
154
|
+
|
|
155
|
+
/** Plugin-owned catalog path inside the Harness home. */
|
|
156
|
+
export function qoderCatalogPath(filename = '.qoder-catalog.json') {
|
|
157
|
+
return join(resolveDshHome(), filename)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The last catalog that actually loaded, per region.
|
|
162
|
+
*
|
|
163
|
+
* A restart must not drop the user to an empty model group when a good catalog
|
|
164
|
+
* was fetched minutes earlier, and a temporary upstream failure must not either.
|
|
165
|
+
* Only model metadata is stored — never a token.
|
|
166
|
+
*/
|
|
167
|
+
class CatalogStore {
|
|
168
|
+
constructor(region, logger) {
|
|
169
|
+
this.path = qoderCatalogPath(`.qoder-catalog.${region.id}.json`)
|
|
170
|
+
this.logger = logger
|
|
171
|
+
this.entries = []
|
|
172
|
+
this.fetchedAt = 0
|
|
173
|
+
this.load()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
load() {
|
|
177
|
+
if (!existsSync(this.path)) return
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8'))
|
|
180
|
+
if (parsed?.version !== CATALOG_FORMAT_VERSION) return
|
|
181
|
+
if (!Array.isArray(parsed.entries)) return
|
|
182
|
+
this.entries = parsed.entries
|
|
183
|
+
this.fetchedAt = Number(parsed.fetchedAt) || 0
|
|
184
|
+
} catch {
|
|
185
|
+
// A damaged cache is simply ignored; the next fetch replaces it.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
save() {
|
|
190
|
+
try {
|
|
191
|
+
mkdirSync(join(this.path, '..'), { recursive: true })
|
|
192
|
+
writeFileSync(
|
|
193
|
+
this.path,
|
|
194
|
+
JSON.stringify({ version: CATALOG_FORMAT_VERSION, fetchedAt: this.fetchedAt, entries: this.entries }, null, 2),
|
|
195
|
+
'utf8',
|
|
196
|
+
)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.logger?.warn?.(`dsh-connect-qoder: could not save catalog ${this.path}`, error)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
current() {
|
|
203
|
+
return this.entries
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fresh() {
|
|
207
|
+
return Date.now() - this.fetchedAt < CATALOG_TTL_MS
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
replace(entries) {
|
|
211
|
+
this.entries = entries
|
|
212
|
+
this.fetchedAt = Date.now()
|
|
213
|
+
this.save()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A model id is the catalog display name with whitespace removed, so the id is
|
|
219
|
+
* stable and readable; the upstream key is tracked beside it for the wire.
|
|
220
|
+
*/
|
|
221
|
+
function modelIdFor(entry) {
|
|
222
|
+
return (entry.display_name || 'QoderModel').replace(/\s+/g, '')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Reshape one entry from {@link fetchModels} for storage and lookup.
|
|
227
|
+
*
|
|
228
|
+
* The catalog interpretation (vision, reasoning, effort support, and the
|
|
229
|
+
* always-thinking rule) already happened in `fetchModels`, so this only adds
|
|
230
|
+
* the user-facing id.
|
|
231
|
+
*/
|
|
232
|
+
function normalizeEntry(entry) {
|
|
233
|
+
return {
|
|
234
|
+
id: modelIdFor({ display_name: entry.name }),
|
|
235
|
+
key: entry.key,
|
|
236
|
+
name: entry.name,
|
|
237
|
+
isVL: entry.isVL === true,
|
|
238
|
+
isReasoning: entry.isReasoning === true,
|
|
239
|
+
supportsEffort: entry.supportsEffort === true,
|
|
240
|
+
alwaysThinking: entry.alwaysThinking === true,
|
|
241
|
+
effortLevels: Array.isArray(entry.effortLevels) ? entry.effortLevels : [],
|
|
242
|
+
maxInputTokens: entry.maxInputTokens ?? 0,
|
|
243
|
+
// `toPiModel` sizes each model from these two, so dropping them here (which
|
|
244
|
+
// is what happened before) made `useMaximumContextWindow` do nothing at all:
|
|
245
|
+
// with no `contextOptions` the "widest offered window" is 0, the switch has
|
|
246
|
+
// nothing to prefer, and every model falls back to `max_input_tokens` — a
|
|
247
|
+
// smaller per-request floor, not the capacity the catalog advertises. They
|
|
248
|
+
// are carried through verbatim so the setting and the picker agree.
|
|
249
|
+
defaultContextWindow: entry.defaultContextWindow ?? 0,
|
|
250
|
+
contextOptions: Array.isArray(entry.contextOptions) ? entry.contextOptions : [],
|
|
251
|
+
// The credit multiplier Qoder charges for this model. It is display-only,
|
|
252
|
+
// but the picker shows it beside the name so the cost of a choice is visible
|
|
253
|
+
// before the request is sent. `toPiModel` is what formats it.
|
|
254
|
+
priceFactor: Number(entry.priceFactor) || 0,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Owns one region: its credential, catalog, shim, adapter, and registration.
|
|
260
|
+
*/
|
|
261
|
+
class RegionRuntime {
|
|
262
|
+
constructor(region, ctx) {
|
|
263
|
+
this.region = region
|
|
264
|
+
this.ctx = ctx
|
|
265
|
+
this.logger = ctx.logger
|
|
266
|
+
this.catalog = new CatalogStore(region, ctx.logger)
|
|
267
|
+
this.credential = undefined
|
|
268
|
+
this.exchanged = undefined
|
|
269
|
+
this.refreshTimer = undefined
|
|
270
|
+
/** Last usage reading and when it was taken; see {@link RegionRuntime.readUsage}. */
|
|
271
|
+
this.usage = undefined
|
|
272
|
+
this.usageAt = 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolve the current credential, exchanging a PAT when that is the source.
|
|
277
|
+
*
|
|
278
|
+
* The Qoder app owns the token's lifecycle and refreshes it in its own
|
|
279
|
+
* store, so an expired token is re-read from disk rather than cached — a
|
|
280
|
+
* user who reopened the app is picked up on the next request, with no
|
|
281
|
+
* restart. A PAT, by contrast, is exchanged once and then reused until its
|
|
282
|
+
* own expiry.
|
|
283
|
+
*/
|
|
284
|
+
async resolveCredential() {
|
|
285
|
+
if (this.exchanged !== undefined) {
|
|
286
|
+
if (this.exchanged.source === 'env-pat' && this.exchanged.expiresAt > Date.now()) return this.exchanged
|
|
287
|
+
if (this.exchanged.source !== 'env-pat' && !this.exchanged.expired) return this.exchanged
|
|
288
|
+
}
|
|
289
|
+
const fromApp = loadCredential(this.region, process.env.APPDATA ?? '')
|
|
290
|
+
const credential = fromApp ?? loadEnvCredential(this.region)
|
|
291
|
+
this.credential = credential
|
|
292
|
+
if (credential === undefined) {
|
|
293
|
+
this.exchanged = undefined
|
|
294
|
+
return undefined
|
|
295
|
+
}
|
|
296
|
+
if (credential.source === 'env-pat') {
|
|
297
|
+
const exchanged = await exchangePat(this.region, credential.token)
|
|
298
|
+
this.exchanged = {
|
|
299
|
+
...credential,
|
|
300
|
+
token: exchanged.token,
|
|
301
|
+
refreshToken: exchanged.refreshToken,
|
|
302
|
+
expiresAt: exchanged.expiresAt,
|
|
303
|
+
}
|
|
304
|
+
return this.exchanged
|
|
305
|
+
}
|
|
306
|
+
this.exchanged = credential
|
|
307
|
+
return credential
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Refresh the model catalog from upstream, keeping the last good one on failure. */
|
|
311
|
+
async refreshCatalog(force = false) {
|
|
312
|
+
if (!force && this.catalog.fresh()) return
|
|
313
|
+
let credential
|
|
314
|
+
try {
|
|
315
|
+
credential = await this.resolveCredential()
|
|
316
|
+
} catch (error) {
|
|
317
|
+
this.logger?.warn?.(`dsh-connect-qoder: ${this.region.displayName} credential resolution failed`, error)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if (credential === undefined) return
|
|
321
|
+
try {
|
|
322
|
+
const raw = await fetchModels(this.region, credential)
|
|
323
|
+
const entries = raw.map(normalizeEntry)
|
|
324
|
+
if (entries.length > 0) {
|
|
325
|
+
this.catalog.replace(entries)
|
|
326
|
+
this.invalidate?.()
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
this.logger?.warn?.(`dsh-connect-qoder: ${this.region.displayName} catalog refresh failed`, error)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Look up the upstream key for a user-facing model id. */
|
|
334
|
+
upstreamKey(modelId) {
|
|
335
|
+
const found = this.catalog.current().find((entry) => entry.id === modelId)
|
|
336
|
+
return found?.key
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The catalog entry behind a user-facing model id. */
|
|
340
|
+
entryFor(modelId) {
|
|
341
|
+
return this.catalog.current().find((entry) => entry.id === modelId)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Read the account's usage, with a short cache.
|
|
346
|
+
*
|
|
347
|
+
* The card is the only consumer and it can be expanded repeatedly, so a
|
|
348
|
+
* reading taken moments ago is reused; `force` is what the panel's refresh
|
|
349
|
+
* button asks for. A failure is never cached, so a transient upstream problem
|
|
350
|
+
* does not pin the panel to an error state.
|
|
351
|
+
*/
|
|
352
|
+
async readUsage(force = false) {
|
|
353
|
+
if (!force && this.usage !== undefined && Date.now() - this.usageAt < USAGE_TTL_MS) {
|
|
354
|
+
return this.usage
|
|
355
|
+
}
|
|
356
|
+
const credential = await this.resolveCredential()
|
|
357
|
+
if (credential === undefined) return undefined
|
|
358
|
+
const usage = await fetchUsage(this.region, credential)
|
|
359
|
+
this.usage = usage
|
|
360
|
+
this.usageAt = Date.now()
|
|
361
|
+
return usage
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Start one region's shim and catalog lifecycle.
|
|
367
|
+
*
|
|
368
|
+
* @param enabledIdsFor - `(regionId) => string[]`, the user's roster choice. The
|
|
369
|
+
* shim needs it so `GET /v1/models` — the endpoint the picker's discovery
|
|
370
|
+
* reads — narrows the catalog the same way the adapter does.
|
|
371
|
+
* @returns the runtime plus its shim, or `undefined` when the shim could not
|
|
372
|
+
* listen (the region is then simply absent rather than fatal).
|
|
373
|
+
*/
|
|
374
|
+
async function startRegion(region, ctx, enabledIdsFor) {
|
|
375
|
+
const runtime = new RegionRuntime(region, ctx)
|
|
376
|
+
|
|
377
|
+
// A region is published only when it can actually answer. Registering a
|
|
378
|
+
// provider whose sign-in is missing or expired puts a dead route in the model
|
|
379
|
+
// picker: selecting it sends a request the gateway answers with 403, which
|
|
380
|
+
// the UI reports as "the provider rejected this request" — blaming the user's
|
|
381
|
+
// account for a channel this plugin should never have offered. Skipping the
|
|
382
|
+
// region leaves the other one working and the picker honest.
|
|
383
|
+
let credential
|
|
384
|
+
try {
|
|
385
|
+
credential = await runtime.resolveCredential()
|
|
386
|
+
} catch (error) {
|
|
387
|
+
ctx.logger.warn(
|
|
388
|
+
`dsh-connect-qoder: ${region.displayName} sign-in is unusable; region not registered`,
|
|
389
|
+
error,
|
|
390
|
+
)
|
|
391
|
+
return undefined
|
|
392
|
+
}
|
|
393
|
+
if (credential === undefined) {
|
|
394
|
+
ctx.logger.info(
|
|
395
|
+
`dsh-connect-qoder: ${region.displayName} has no local sign-in; region not registered`,
|
|
396
|
+
)
|
|
397
|
+
return undefined
|
|
398
|
+
}
|
|
399
|
+
if (credential.expired === true) {
|
|
400
|
+
ctx.logger.warn(
|
|
401
|
+
`dsh-connect-qoder: ${region.displayName} sign-in has expired; region not registered ` +
|
|
402
|
+
`(open the ${region.displayName} app to renew it, then restart DSH)`,
|
|
403
|
+
)
|
|
404
|
+
return undefined
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const shim = createQoderShim({
|
|
408
|
+
region,
|
|
409
|
+
resolveCredential: () => runtime.resolveCredential(),
|
|
410
|
+
resolveModels: () => runtime.catalog.current(),
|
|
411
|
+
resolveUpstreamKey: (id) => runtime.upstreamKey(id),
|
|
412
|
+
resolveAlwaysThinking: (id) => runtime.entryFor(id)?.alwaysThinking === true,
|
|
413
|
+
// Read on every listing so curating the roster reaches the picker live.
|
|
414
|
+
resolveEnabledIds: () => enabledIdsFor(region.id),
|
|
415
|
+
logger: ctx.logger,
|
|
416
|
+
})
|
|
417
|
+
try {
|
|
418
|
+
await shim.ready
|
|
419
|
+
} catch (error) {
|
|
420
|
+
ctx.logger.error(`dsh-connect-qoder: ${region.displayName} loopback endpoint failed to start`, error)
|
|
421
|
+
return undefined
|
|
422
|
+
}
|
|
423
|
+
return { runtime, shim }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Register every Qoder region.
|
|
428
|
+
*
|
|
429
|
+
* All regions are published through one adapter and one registration pair:
|
|
430
|
+
* `registerAdapter` maps a set of providers onto a single adapter, so calling
|
|
431
|
+
* it once per region would leave only the last provider owned.
|
|
432
|
+
*
|
|
433
|
+
* Activation is best-effort by design: this plugin contributes optional model
|
|
434
|
+
* routes, and a failure to do so must never take the profile down with it. The
|
|
435
|
+
* whole body is therefore guarded, so a fault here degrades to "Qoder models
|
|
436
|
+
* are absent" rather than a profile that cannot boot.
|
|
437
|
+
*
|
|
438
|
+
* @param ctx - the plugin context, with `llm` injected.
|
|
439
|
+
* @param config - the resolved plugin configuration.
|
|
440
|
+
*/
|
|
441
|
+
export async function apply(ctx, config = {}) {
|
|
442
|
+
try {
|
|
443
|
+
await activate(ctx, config)
|
|
444
|
+
} catch (error) {
|
|
445
|
+
ctx.logger.error('dsh-connect-qoder: activation failed; Qoder models will be unavailable', error)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The real activation sequence, called under {@link apply}'s guard. */
|
|
450
|
+
async function activate(ctx, config) {
|
|
451
|
+
// The context-window, image, and roster preferences are read through a mutable
|
|
452
|
+
// holder so the settings section can change them without re-registering the
|
|
453
|
+
// adapter; the adapter re-reads them every time it builds a model list, and the
|
|
454
|
+
// shim re-reads them on every listing. The resolved config is the initial value.
|
|
455
|
+
let preferences = { ...config }
|
|
456
|
+
let invalidateAdapter = () => {}
|
|
457
|
+
|
|
458
|
+
/** The models the user enabled for one region; empty means "no filter". */
|
|
459
|
+
const enabledIdsFor = (regionId) => {
|
|
460
|
+
const byRegion = preferences.enabledModelIds
|
|
461
|
+
if (byRegion === null || typeof byRegion !== 'object') return []
|
|
462
|
+
const list = byRegion[regionId]
|
|
463
|
+
return Array.isArray(list) ? list.filter((id) => typeof id === 'string' && id.length > 0) : []
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const started = []
|
|
467
|
+
for (const region of REGIONS) {
|
|
468
|
+
const entry = await startRegion(region, ctx, enabledIdsFor)
|
|
469
|
+
if (entry !== undefined) started.push(entry)
|
|
470
|
+
}
|
|
471
|
+
if (started.length === 0) {
|
|
472
|
+
ctx.logger.warn('dsh-connect-qoder: no Qoder region could start')
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const { adapter, invalidate } = createQoderAdapter({
|
|
477
|
+
regions: started.map(({ runtime, shim }) => ({
|
|
478
|
+
region: runtime.region,
|
|
479
|
+
shim,
|
|
480
|
+
catalog: () => runtime.catalog.current(),
|
|
481
|
+
})),
|
|
482
|
+
preferMaximumContext: () => preferences.useMaximumContextWindow === true,
|
|
483
|
+
imageModeFor: (modelId) => {
|
|
484
|
+
const overrides = preferences.imageOverrides
|
|
485
|
+
if (overrides === null || typeof overrides !== 'object') return 'auto'
|
|
486
|
+
const saved = overrides[modelId]
|
|
487
|
+
return saved === 'on' || saved === 'off' ? saved : 'auto'
|
|
488
|
+
},
|
|
489
|
+
enabledIdsFor,
|
|
490
|
+
})
|
|
491
|
+
invalidateAdapter = invalidate
|
|
492
|
+
for (const { runtime } of started) {
|
|
493
|
+
runtime.invalidate = () => {
|
|
494
|
+
invalidate()
|
|
495
|
+
ctx.emit('llm/adapters-updated')
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let releaseAdapter
|
|
500
|
+
let releaseDirectory
|
|
501
|
+
try {
|
|
502
|
+
releaseAdapter = ctx.llm.registerAdapter(
|
|
503
|
+
started.map(({ runtime }) => runtime.region.id),
|
|
504
|
+
adapter,
|
|
505
|
+
)
|
|
506
|
+
releaseDirectory = ctx.llm.registerConfigurableProviders(
|
|
507
|
+
started.map(({ runtime }) => ({
|
|
508
|
+
provider: runtime.region.id,
|
|
509
|
+
displayName: runtime.region.displayName,
|
|
510
|
+
settingsNs: QODER_SETTINGS_NS,
|
|
511
|
+
settingsPath: [],
|
|
512
|
+
declared: false,
|
|
513
|
+
})),
|
|
514
|
+
)
|
|
515
|
+
} catch (error) {
|
|
516
|
+
releaseAdapter?.()
|
|
517
|
+
releaseDirectory?.()
|
|
518
|
+
for (const { shim } of started) shim.close()
|
|
519
|
+
ctx.logger.error('dsh-connect-qoder: provider registration failed', error)
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
ctx.effect(() => () => {
|
|
524
|
+
releaseAdapter?.()
|
|
525
|
+
releaseDirectory?.()
|
|
526
|
+
for (const { runtime, shim } of started) {
|
|
527
|
+
if (runtime.refreshTimer !== undefined) clearInterval(runtime.refreshTimer)
|
|
528
|
+
shim.close()
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// Publish the settings section. Without this the namespace a provider
|
|
533
|
+
// directory entry points at would hold no schema, and no configuration
|
|
534
|
+
// surface could render a control for this route — context-window choice
|
|
535
|
+
// included. The section is optional: a host without a settings service still
|
|
536
|
+
// gets working models, just no configuration surface.
|
|
537
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
538
|
+
try {
|
|
539
|
+
settingsCtx.settings.installSection(ctx, QODER_SETTINGS_NS, QODER_SECTION, config, {
|
|
540
|
+
setSource(source) {
|
|
541
|
+
preferences = { ...preferences, ...source() }
|
|
542
|
+
},
|
|
543
|
+
onChange() {
|
|
544
|
+
// The capabilities a model advertises changed, so the picker must
|
|
545
|
+
// rebuild — and so must any other reader of the model catalog.
|
|
546
|
+
invalidateAdapter()
|
|
547
|
+
ctx.emit('llm/adapters-updated')
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
} catch (error) {
|
|
551
|
+
ctx.logger.warn('dsh-connect-qoder: settings section unavailable', error)
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// The settings card needs the live model roster to render one row per model,
|
|
556
|
+
// and it cannot read the host catalog directly. This route is the only path,
|
|
557
|
+
// so it is mounted on the optional webServer context and answers with the
|
|
558
|
+
// catalog the adapter already holds — metadata only, never a credential.
|
|
559
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
560
|
+
try {
|
|
561
|
+
webCtx.effect(() =>
|
|
562
|
+
webCtx.webServer.register({
|
|
563
|
+
kind: 'exact',
|
|
564
|
+
path: QODER_MODELS_PATH,
|
|
565
|
+
handler: async (req, res) => {
|
|
566
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
567
|
+
if (!loopbackRequest(req)) return sendJson(res, 403, { error: 'origin-not-trusted' })
|
|
568
|
+
// `refresh=1` re-reads the catalog from upstream. It matters because
|
|
569
|
+
// both the roster and the rates move on their own: Qoder adds and
|
|
570
|
+
// retires models, and an off-peak discount flips the effective price
|
|
571
|
+
// at 22:00 and 08:00 Asia/Shanghai. Without this the picker would
|
|
572
|
+
// keep showing whatever was true at process start.
|
|
573
|
+
const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('refresh') === '1'
|
|
574
|
+
if (force) {
|
|
575
|
+
for (const { runtime } of started) {
|
|
576
|
+
try {
|
|
577
|
+
await runtime.refreshCatalog(true)
|
|
578
|
+
} catch (error) {
|
|
579
|
+
// A refresh failure must not blank the card: the last good
|
|
580
|
+
// catalog is still served below.
|
|
581
|
+
ctx.logger.warn(
|
|
582
|
+
`dsh-connect-qoder: ${runtime.region.displayName} catalog refresh failed`,
|
|
583
|
+
error,
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const now = new Date()
|
|
589
|
+
const models = []
|
|
590
|
+
for (const { runtime } of started) {
|
|
591
|
+
for (const entry of runtime.catalog.current()) {
|
|
592
|
+
models.push({
|
|
593
|
+
id: entry.id,
|
|
594
|
+
name: entry.name,
|
|
595
|
+
region: runtime.region.id,
|
|
596
|
+
regionName: runtime.region.displayName,
|
|
597
|
+
isVL: entry.isVL === true,
|
|
598
|
+
// The raw catalog multiplier, kept for reference.
|
|
599
|
+
priceFactor: Number(entry.priceFactor) || 0,
|
|
600
|
+
// The multiplier that applies right now, with the off-peak
|
|
601
|
+
// window resolved — this is what the picker appends to the name.
|
|
602
|
+
effectiveRate: rateNow(entry, now),
|
|
603
|
+
offPeakActive: offPeakActive(entry, now),
|
|
604
|
+
...(entry.promotion !== undefined
|
|
605
|
+
? {
|
|
606
|
+
promotion: {
|
|
607
|
+
badge: entry.promotion.badge,
|
|
608
|
+
description: entry.promotion.description,
|
|
609
|
+
windowStart: entry.promotion.windowStart,
|
|
610
|
+
windowEnd: entry.promotion.windowEnd,
|
|
611
|
+
discountFactor: entry.promotion.discountFactor,
|
|
612
|
+
beforePromotionPriceFactor: entry.promotion.beforePromotionPriceFactor,
|
|
613
|
+
remainingSeconds: offPeakRemaining(entry, now),
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
: {}),
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const overrides = preferences.imageOverrides
|
|
621
|
+
const enabled = preferences.enabledModelIds
|
|
622
|
+
sendJson(res, 200, {
|
|
623
|
+
models,
|
|
624
|
+
imageOverrides: overrides !== null && typeof overrides === 'object' ? overrides : {},
|
|
625
|
+
useMaximumContextWindow: preferences.useMaximumContextWindow === true,
|
|
626
|
+
enabledModelIds: enabled !== null && typeof enabled === 'object' ? enabled : {},
|
|
627
|
+
refreshedAt: Date.now(),
|
|
628
|
+
})
|
|
629
|
+
},
|
|
630
|
+
}),
|
|
631
|
+
)
|
|
632
|
+
} catch (error) {
|
|
633
|
+
ctx.logger.warn('dsh-connect-qoder: model route unavailable', error)
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
// The usage panel needs the same treatment: the quota lives upstream behind a
|
|
638
|
+
// bearer token the browser must never hold, so the host reads it and hands the
|
|
639
|
+
// card a credential-free summary. Each region is read independently and a
|
|
640
|
+
// failing region is reported as unavailable rather than failing the panel, so
|
|
641
|
+
// one dead sign-in cannot hide the other region's numbers.
|
|
642
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
643
|
+
try {
|
|
644
|
+
webCtx.effect(() =>
|
|
645
|
+
webCtx.webServer.register({
|
|
646
|
+
kind: 'exact',
|
|
647
|
+
path: QODER_USAGE_PATH,
|
|
648
|
+
handler: async (req, res) => {
|
|
649
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
650
|
+
if (!loopbackRequest(req)) return sendJson(res, 403, { error: 'origin-not-trusted' })
|
|
651
|
+
const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('refresh') === '1'
|
|
652
|
+
const regions = await Promise.all(
|
|
653
|
+
started.map(async ({ runtime }) => {
|
|
654
|
+
const base = {
|
|
655
|
+
region: runtime.region.id,
|
|
656
|
+
regionName: runtime.region.displayName,
|
|
657
|
+
manageUrl: runtime.region.manageUrl,
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
const usage = await runtime.readUsage(force)
|
|
661
|
+
return usage === undefined ? { ...base, available: false } : { ...base, available: true, ...usage }
|
|
662
|
+
} catch (error) {
|
|
663
|
+
ctx.logger.warn(
|
|
664
|
+
`dsh-connect-qoder: ${runtime.region.displayName} usage read failed`,
|
|
665
|
+
error,
|
|
666
|
+
)
|
|
667
|
+
return { ...base, available: false }
|
|
668
|
+
}
|
|
669
|
+
}),
|
|
670
|
+
)
|
|
671
|
+
sendJson(res, 200, { regions })
|
|
672
|
+
},
|
|
673
|
+
}),
|
|
674
|
+
)
|
|
675
|
+
} catch (error) {
|
|
676
|
+
ctx.logger.warn('dsh-connect-qoder: usage route unavailable', error)
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
// Load credentials and catalogs without blocking activation: the providers
|
|
681
|
+
// are registered either way, and an empty catalog is how DSH hides a model
|
|
682
|
+
// group until a sign-in appears.
|
|
683
|
+
for (const { runtime } of started) {
|
|
684
|
+
void runtime.refreshCatalog(true).then(() => {
|
|
685
|
+
runtime.refreshTimer = setInterval(() => {
|
|
686
|
+
void runtime.refreshCatalog(true)
|
|
687
|
+
}, CATALOG_TTL_MS)
|
|
688
|
+
runtime.refreshTimer.unref?.()
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
}
|