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/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
+ }