Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
1309 lines (1261 loc) · 64.7 KB
/
Copy pathindex.js
File metadata and controls
1309 lines (1261 loc) · 64.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* dsh-commandcode-goat — host half.
*
* Publishes a Command Code subscription (GOAT / Pro / Max) into DeepSeek
* Harness as first-class provider routes, and adds the account surface the
* gateway's own CLI has: usage, credit windows, and web search.
*
* ## Why this plugin owns no adapter
*
* It writes provider *profiles* into the first-party `llm-pi-ai` settings
* section instead of implementing `LlmAdapter`. The harness then serves those
* routes with the adapter it already ships, which is where streaming, tool
* calling, reasoning dispatch, image handling and replay are implemented and
* tested. An out-of-tree adapter would have to re-derive all of it from the
* chunk protocol and stay correct across releases; a profile only has to state
* facts — which endpoint, which protocol, which models, which capabilities.
*
* The trade is that the routes a profile creates are the provider plugin's,
* not this plugin's, so what a sync writes is a user-visible section the user
* can read and edit in Settings → Models. Syncing is therefore idempotent and
* deliberately conservative — it refreshes `models` and never overwrites an
* `apiKeyEnv`, `baseURL`, `compat` or `displayName` a route already carries —
* but it is not hidden behind a button. `autoSync` is on by default, because a
* plugin whose whole job is to make a subscription selectable otherwise looks
* like it installed nothing at all: no provider row appears in
* Settings → Models, so there is nowhere to put the key, and the plugin reads
* as broken rather than as pending.
*
* The first tick is delayed rather than immediate: it writes into another
* plugin's section, and doing that during startup races the provider plugin's
* own registration. A few seconds of waiting costs nothing and removes the
* race.
*
* ## What the two upstream sources are for
*
* `GET /provider/v1/models` is the live list and the routing truth — it states
* which endpoints each model answers on. The GOAT plan page carries the
* capability catalog the list omits: thinking, vision, and which subscription
* tier each model starts at. Joining them is what lets one sync produce routes
* that are both complete and correctly scoped to the user's tier.
*/
import Schema from '@deepseek-ai/schemastery'
import { readFileSync } from 'node:fs'
import {
DEFAULT_ALPHA_BASE_URL,
DEFAULT_API_KEY_ENV,
DEFAULT_BASE_URL,
DEFAULT_CATALOG_URL,
DEFAULT_SOURCE_URL,
PLANS,
ROUTE_ORDER,
buildEntries,
fetchCatalog,
fetchModelList,
providerKey,
tierAvailability,
} from './catalog.js'
import { SyncError, SYNC_CODES, hasCustomRouteConfiguration, pruneGeneratedRoutes, readPiAiValue, sectionsReferencing, syncPlan } from './pi-ai.js'
import { probeRoutes } from './probe.js'
import { catalogPageCandidates, parseGeneratedKey } from './plans.js'
import { describeUsage, fetchUsageReport } from './usage.js'
import { makeSearchProvider, makeSelectionController, SEARCH_PROVIDER_ID } from './search.js'
import { makeBridgeRoutes } from './bridge.js'
import { providerLifecycle } from './provider-lifecycle.js'
import { profileState } from './profile-state.js'
import { applyModelPreferences, diffModels, inventoryFromProviders, preserveModelOverrides, validateModelPreferences, REASONING_PRESETS } from './model-management.js'
import { DEFAULT_ALERT_THRESHOLDS, evaluateAlerts, modelChangeEvents, appendNotifications, validateAlertThresholds } from './account-notifications.js'
import { exportConfiguration, previewConfigurationImport, configurationSignature, validatePortableConfiguration } from './configuration-transfer.js'
import { abortable } from './cancellation.js'
import { diagnoseModel, routeForProtocol, DIAGNOSTIC_MODES } from './diagnostics.js'
import { DEFAULT_CONTEXT_GUARD_RATIO, installContextGuard, installOverflowRecovery, routeKeySet } from './recovery.js'
/** Cordis plugin name. */
export const name = 'dsh-commandcode-goat'
/**
* This build's version, read from the installed manifest.
*
* Reported through the settings bridge so the card can print it. Without it,
* "which build is actually loaded" is unanswerable from the UI — and since a
* `github:` install pins a commit in the lockfile, an update that was never
* applied looks exactly like a fix that did not work.
*/
export const PLUGIN_VERSION = (() => {
try {
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? 'unknown'
} catch {
return 'unknown'
}
})()
/**
* The row id this bundle's patch declares, for a context that cannot name it.
*
* This is the pairing key: the host half serves the settings section and the
* browser half registers its card under it. Since dsh 0.1.7 a settings section
* *is* a plugin entry, so the key is that entry's **row id** — `commandcode-goat`,
* exactly as `cordis.patch.yml` declares it — and not this package's name, which
* is a different string used for a different purpose (the client module id, and
* the `plugins.bundle.config` address).
*
* It is only a fallback because the profile owns the row: the mount reads the
* real id off the fiber, reports it through the bridge, and the card rebinds to
* whatever the host says. This constant is what a context with no fiber — a
* programmatic mount, a test — binds to.
*/
export const SETTINGS_NS = 'commandcode-goat'
/**
* Nothing is hard-injected. Every seam this plugin uses — settings, web,
* webServer, tools, credentials — is attached through an optional `ctx.inject`
* so the row also loads in a headless or SDK profile where most of them are
* absent, and simply does less there.
*/
export const inject = []
/** Ceiling on one sync's network work: two fetches and one settings write. */
export const SYNC_TIMEOUT_MS = 45_000
/** Default auto-sync cadence. A catalog this size does not move hourly. */
export const DEFAULT_AUTO_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000
/** Floor on the auto-sync cadence, so a mis-typed 0 cannot become a hot loop. */
export const MIN_AUTO_SYNC_INTERVAL_MS = 60_000
/** Larger native timer delays overflow to a one-millisecond wait in Node. */
export const MAX_TIMER_DELAY_MS = 2 ** 31 - 1
/**
* The configuration fields one sync run actually reads.
*
* A sync publishes routes from exactly these: the tier, the two sources, the
* ids and capabilities it merges in, the window cap, and the targeting the
* route profiles carry (plus the sweep's own switch). Everything else a card
* can change — the search switch, the usage tool, the guard ratio — has no
* bearing on what gets written, and a reader who toggles one of them while
* discovery is in flight must not turn a valid run into `config-changed`.
*/
export const SYNC_CONFIG_KEYS = [
'plan',
'sourceURL',
'catalogURL',
'extraIds',
'includeReasoningEfforts',
'maxContextWindow',
'targetApiKeyEnv',
'targetBaseURL',
'targetCompat',
'pruneOtherPlans',
'modelPreferences',
'onlyFavorites',
]
/**
* A comparable snapshot of the fields above.
*
* @param cfg - normalized plugin config.
* @returns a stable string that changes only when a sync-relevant field does.
*/
export function syncConfigSignature(cfg) {
return JSON.stringify(SYNC_CONFIG_KEYS.map((key) => cfg?.[key] ?? null))
}
/**
* Ceiling on the catalog read a status call may wait for.
*
* Short on purpose: a card is being drawn, and a reader would rather see "not
* known yet" beside a tier than a spinner in front of a page that is not
* loading quickly. The sync that follows has its own, longer budget.
*/
export const CATALOG_WARMUP_TIMEOUT_MS = 8_000
/**
* How long the first automatic sync waits after the plugin mounts.
*
* It writes into another plugin's settings section, so it has to let the rest
* of the tree finish composing first — including the provider plugin whose
* routes are being written. Long enough for that, short enough that a user who
* restarts and immediately opens Settings → Models finds the provider row
* already there.
*/
export const FIRST_SYNC_DELAY_MS = 15_000
/**
* Read the capability catalog, following the selected tier's own page.
*
* The catalog is published on the Go, GOAT and Pro pages and is identical on
* all three; the Max page carries a different payload with no catalog array in
* it. So the default source is read as "whichever page states this tier's
* catalog", with the page that always answers as the fallback — a Max user then
* gets the same enriched sync as everyone else instead of a silent downgrade.
* A source URL the user typed is used verbatim: following a tier is this
* plugin's default behaviour, not something to impose on an explicit choice.
*
* @returns `{ entries, source, fallbackFrom }`; throws only when every
* candidate failed, so the caller can report the catalog as unavailable.
*/
export async function readCatalog({ catalogURL, plan, signal }) {
const candidates = catalogURL === DEFAULT_CATALOG_URL ? catalogPageCandidates(plan) : [catalogURL]
const failures = []
for (const url of candidates) {
signal?.throwIfAborted()
try {
const entries = await fetchCatalog(url, signal)
const first = candidates[0]
return { entries, source: url, fallbackFrom: url === first ? undefined : first }
} catch (error) {
signal?.throwIfAborted()
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(failures.join('; '))
}
const configSchema = Schema.object({
sourceURL: Schema.string()
.default(DEFAULT_SOURCE_URL)
.description('Live model list. Answers with { data: [...] }.'),
catalogURL: Schema.string()
.default(DEFAULT_CATALOG_URL)
.description('Official plan page carrying the per-model capability catalog; a failure here degrades the sync instead of failing it.'),
plan: Schema.union(PLANS)
.default('goat')
.description('Subscription tier to generate providers for. Tiers are cumulative: pro includes goat, max includes both.'),
targetApiKeyEnv: Schema.string()
.default('')
.description(`Credential reference the generated routes resolve their key through. Empty means ${DEFAULT_API_KEY_ENV}.`),
targetBaseURL: Schema.string()
.default('')
.description(`Chat base URL written onto a route that has none. Empty means ${DEFAULT_BASE_URL}.`),
targetCompat: Schema.object({
thinkingFormat: Schema.string(),
supportsReasoningEffort: Schema.boolean(),
})
.description('Override for the OpenAI-shaped route\'s compat block. Empty writes the reasoning-enabling default.'),
extraIds: Schema.array(Schema.string())
.default([])
.description('Private model ids to publish alongside the catalog, on the OpenAI-shaped route.'),
includeReasoningEfforts: Schema.boolean()
.default(false)
.description('Declare an identity effort map on models the catalog marks as reasoning. Off by default: the catalog names no wire values.'),
maxContextWindow: Schema.number()
.step(1).min(0)
.default(0)
.description('Escape hatch, not a fix: cap the context window written onto every generated model, in tokens. 0 keeps whatever the vendor states, which is what a route should publish. Lowering it makes DSH summarise earlier — and permanently — instead of letting the session use the window the subscription sells, so reach for it only if the gateway refuses even reduced requests.'),
recoverUpstreamOverflow: Schema.boolean()
.default(true)
.description('When Command Code refuses or drops a request the harness cannot classify as an overflow (the "path expansion / 512 candidates" 400, or a stream that ends or closes early while the session is already past the guard), prune oversized tool results and retry the request; on a second refusal, compact one region first. Keeps the vendor\'s own context window instead of capping it.'),
contextGuardRatio: Schema.number()
.min(0).max(1)
.default(DEFAULT_CONTEXT_GUARD_RATIO)
.description(`Share of a route's own context window at which this plugin reduces the session before the next request goes out, through the harness' own tool-result pruner (no upstream call). DSH's own compaction line sits at min(0.8 × window, window − output reservation − 65536) — for a 1,000,000-token model that is 800,000 tokens, exactly where Command Code's gateway stops completing requests, so a session can be refused on the request that would have crossed it. The default ${DEFAULT_CONTEXT_GUARD_RATIO} fires earlier and leaves that margin; on a window small enough that the harness' own line is already earlier, the guard steps aside instead of second-guessing it, and 0 disables it everywhere. Only tool results are rewritten: summarization stays the harness' own decision at the harness' own threshold.`),
autoSync: Schema.boolean()
.default(true)
.description('Create the generated routes on first load and refresh them on the configured interval, so the provider row appears in Settings → Models without anyone having to find a button. On by default; set it false to make every write an explicit action.'),
pruneOtherPlans: Schema.boolean()
.default(true)
.description('After a sync, delete the routes this plugin generated for any other tier, so a switch leaves no provider behind that the account can no longer use. Only keys this plugin generates are touched, and a route carrying modelOverrides is reported instead of deleted.'),
autoSyncIntervalMs: Schema.number()
.default(DEFAULT_AUTO_SYNC_INTERVAL_MS)
.description('Auto-sync cadence in milliseconds; at least one minute.'),
webSearch: Schema.boolean()
.default(false)
.description('Back the model-facing web_search tool with this account, so no separate search credential is needed.'),
usageBaseURL: Schema.string()
.default(DEFAULT_ALPHA_BASE_URL)
.description('API root for the /alpha/* usage and search endpoints, distinct from the chat base.'),
enableUsageTool: Schema.boolean()
.default(true)
.description('Register the commandcode_usage tool so a model can read the account itself.'),
enableBridge: Schema.boolean()
.default(true)
.description('Serve the loopback-only endpoints the settings card calls.'),
modelPreferences: Schema.dict(Schema.object({
favorite: Schema.boolean(), hidden: Schema.boolean(),
reasoningPreset: Schema.union(REASONING_PRESETS),
reasoningEfforts: Schema.union([Schema.const(false), Schema.dict(Schema.union([Schema.string(), Schema.const(null)]))]),
contextWindow: Schema.number().step(1).min(1), maxTokens: Schema.number().step(1).min(1),
})).default({}).description('Favorite, hide and configure individual models.'),
onlyFavorites: Schema.boolean().default(false).description('Publish only favorite models.'),
modelChangeNotifications: Schema.boolean().default(true).description('Record model additions, removals and capability changes.'),
alertThresholds: Schema.object({
enabled: Schema.boolean().default(false),
fiveHourPercent: Schema.number().min(1).max(100).default(80),
weeklyPercent: Schema.number().min(1).max(100).default(80),
balanceBelow: Schema.number().min(0).default(5),
}).description('Optional account quota alerts, deduplicated until recovery or window reset.'),
})
/**
* Every field of this plugin is a user preference, so every field is
* `volatile()`.
*
* Since dsh 0.1.7 that marker is what makes a Config field configurable at all:
* it decides which fields `settings.describe()` projects into a form, which
* field paths a settings write accepts, and — because a volatile value is
* handed to the plugin as a live reference rather than a copy — how a running
* instance learns that the user changed something without being remounted.
* Marking is per field, and a volatile field may not sit inside another one,
* which the flat shape here satisfies. `volatile()` returns a marked copy
* rather than mutating, so each field is replaced by its marked form.
*
* The method arrived in schemastery 3.18.4. A deployment resolves this plugin's
* own dependency range, not the harness's copy, so an older schemastery can
* reach here — and it must not take the entry down at import time, where
* "failed to import" names no cause and hides that everything else still works.
*/
export const fieldVolatility = (() => {
for (const [key, field] of Object.entries(configSchema.dict ?? {})) {
if (typeof field?.volatile !== 'function') return 'unsupported'
configSchema.dict[key] = field.volatile()
}
return 'configured'
})()
export const Config = configSchema
/**
* Resolve a config snapshot into the plain shape the rest of the module reads.
*
* Schemastery has already applied defaults and rejected wrong types by the time
* this runs; what is left is the empty-string convention, which cannot be
* expressed in the schema because each empty field means "use this other
* default" rather than "unset".
*/
export function normalizeConfig(config) {
/**
* Read a config field's current value.
*
* A `volatile()` field does not arrive as its value: since dsh 0.1.7 the
* loader hands the plugin a live reference and updates it in place, so the
* value must be read through `get()` at the moment it is wanted. Duck-typing
* the reference keeps a build working on a host that hands the plain value
* instead.
*/
const live = (value) => (
value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
)
const raw = Object.fromEntries(Object.entries(config ?? {}).map(([key, value]) => [key, live(value)]))
const number = (value, fallback, minimum) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback
}
const text = (value, fallback) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback)
return {
sourceURL: text(raw.sourceURL, DEFAULT_SOURCE_URL),
catalogURL: text(raw.catalogURL, DEFAULT_CATALOG_URL),
plan: PLANS.includes(raw.plan) ? raw.plan : 'goat',
targetApiKeyEnv: text(raw.targetApiKeyEnv, DEFAULT_API_KEY_ENV),
targetBaseURL: text(raw.targetBaseURL, DEFAULT_BASE_URL),
targetCompat: raw.targetCompat,
extraIds: Array.isArray(raw.extraIds) ? raw.extraIds.filter((id) => typeof id === 'string' && id.trim() !== '') : [],
includeReasoningEfforts: raw.includeReasoningEfforts === true,
// 0 (and anything unusable) means "publish the vendor's number".
maxContextWindow: Number.isSafeInteger(Number(raw.maxContextWindow)) ? number(raw.maxContextWindow, 0, 0) : 0,
recoverUpstreamOverflow: raw.recoverUpstreamOverflow !== false,
// A share of the route's window; an unusable value falls back to the
// documented default, and 0 (or a share the harness' own compaction line
// already meets) means there is no guard to apply.
contextGuardRatio: number(raw.contextGuardRatio, DEFAULT_CONTEXT_GUARD_RATIO, 0),
autoSync: raw.autoSync !== false,
pruneOtherPlans: raw.pruneOtherPlans !== false,
autoSyncIntervalMs: number(raw.autoSyncIntervalMs, DEFAULT_AUTO_SYNC_INTERVAL_MS, MIN_AUTO_SYNC_INTERVAL_MS),
webSearch: raw.webSearch === true,
usageBaseURL: text(raw.usageBaseURL, DEFAULT_ALPHA_BASE_URL),
enableUsageTool: raw.enableUsageTool !== false,
enableBridge: raw.enableBridge !== false,
modelPreferences: validateModelPreferences(raw.modelPreferences ?? {}),
onlyFavorites: raw.onlyFavorites === true,
modelChangeNotifications: raw.modelChangeNotifications !== false,
alertThresholds: validateAlertThresholds(raw.alertThresholds ?? DEFAULT_ALERT_THRESHOLDS),
}
}
/**
* Mount the plugin.
*
* @param ctx - the plugin's cordis context.
* @param config - the resolved composition entry.
*/
export function apply(ctx, config) {
/**
* The row id the profile composed this plugin under.
*
* Since dsh 0.1.7 a settings section *is* a plugin entry: `describe()` keys
* its descriptors by that row's id, and a write addresses the entry by it. So
* this id is also the namespace the browser half binds its form to, and the
* host reports it rather than letting the card assume one.
*/
const entryId = ctx.fiber?.entry?.options.id ?? SETTINGS_NS
/**
* Without the marker there is no form and no writable field on this entry, so
* say so once, in the log, with the actual cause — a silently unconfigurable
* plugin is the worst of the available outcomes.
*/
if (fieldVolatility !== 'configured') {
ctx.logger?.warn?.(
`[${name}] the resolved @deepseek-ai/schemastery has no Schema.volatile() (it arrived in 3.18.4), so this plugin's settings cannot be edited; install a newer schemastery for this profile`,
)
}
// Volatile fields are live references, so this reads whatever is current
// rather than a snapshot taken when the plugin was mounted.
const resolved = () => normalizeConfig(config)
const settingsService = () => ctx.get('settings')
const lifetime = new AbortController()
let state = profileState(settingsService(), entryId)
ctx.effect(() => () => lifetime.abort(), 'dsh-commandcode-goat: pending requests')
const operationSignal = (timeout, caller) => {
const deadline = AbortSignal.timeout(timeout)
// Node 20.0 lacks AbortSignal.any; linking the lifetime to a per-operation
// controller keeps the declared minimum runtime supported.
const controller = new AbortController()
const sources = [lifetime.signal, deadline, ...(caller ? [caller] : [])]
const release = () => {
for (const source of sources) source.removeEventListener('abort', abort)
}
const abort = () => {
controller.abort()
release()
}
if (sources.some((source) => source.aborted)) abort()
else {
for (const source of sources) source.addEventListener('abort', abort, { once: true })
}
return { signal: controller.signal, release }
}
// ── credential ─────────────────────────────────────────────────────────────
/**
* The account key, resolved once per operation rather than cached, so a key
* stored through Settings → Models reaches the next call without a restart.
* The credentials seam already layers the managed store, the environment and
* `.env` files; the direct `process.env` read is the fallback for a
* deployment that loads no credential provider at all.
*/
const resolveKey = async (envName = resolved().targetApiKeyEnv) => {
const credentials = ctx.get('credentials')
if (credentials !== undefined && typeof credentials.resolve === 'function') {
try {
const found = await credentials.resolve(envName)
if (typeof found?.value === 'string' && found.value !== '') return found.value
} catch { /* fall through to the launch environment */ }
}
const ambient = process.env?.[envName]
return typeof ambient === 'string' && ambient !== '' ? ambient : undefined
}
/**
* Where the account key resolves from, for the card to *state* rather than
* assert. Reads the same two sources in the same order as {@link resolveKey},
* so the two can never disagree about whether a key exists — a card that
* says "not configured" beside a usage panel that just read the account is
* self-contradicting, and the reader has no way to tell which half is wrong.
*
* Reports only the source and the reference name; never the value.
*/
const keyState = async () => {
const envName = resolved().targetApiKeyEnv
const credentials = ctx.get('credentials')
if (credentials !== undefined && typeof credentials.resolve === 'function') {
try {
const found = await credentials.resolve(envName)
if (typeof found?.value === 'string' && found.value !== '') {
return { configured: true, source: found.source === 'env' ? 'environment' : 'credentials', envName }
}
} catch { /* fall through to the launch environment */ }
}
const ambient = process.env?.[envName]
if (typeof ambient === 'string' && ambient !== '') return { configured: true, source: 'environment', envName }
return { configured: false, source: 'none', envName }
}
// ── sync ───────────────────────────────────────────────────────────────────
let providerWriteActive = false
let providerWritePending = Promise.resolve()
let providersReady = Promise.resolve()
const claimedKeys = new Set(ROUTE_ORDER.map((route) => providerKey(resolved().plan, route)))
let activeLifecycle
const claimPlan = (plan) => {
const keys = ROUTE_ORDER.map((route) => providerKey(plan, route))
for (const key of keys) claimedKeys.add(key)
activeLifecycle?.claim(keys)
}
ctx.inject(['settings'], (settingsCtx) => {
state = profileState(settingsCtx.settings, entryId)
const rootContext = ctx.root
const lifecycle = providerLifecycle(settingsCtx.settings, { keys: claimedKeys,
hmr: () => rootContext?.get?.('hmr') ?? ctx.get('hmr') })
activeLifecycle = lifecycle
providersReady = Promise.all([lifecycle.ready, state.ready]).catch((error) => {
ctx.logger?.warn?.(`[${name}] could not restore generated providers: ${error.message}`)
})
const lifecycleLogger = ctx.logger
ctx.effect(() => () => {
lifetime.abort()
// ConfigEditor holds the Loader/HMR lock while disabling this row.
// Awaiting settings.mutate from its disposer would wait on that same
// lock forever. Let disposal finish, then serialize cleanup behind any
// write already in flight. Catch the whole task, including backup I/O.
void providerWritePending.catch(() => {}).then(() => lifecycle.stop()).catch((error) => {
const message = `[${name}] could not remove generated providers; backup retained: ${lifecycle.failureMessage(error)}`
lifecycleLogger?.warn?.(message)
// A disposed row's logger may no longer have a sink; keep failures visible.
console.warn(message)
})
}, 'dsh-commandcode-goat: generated providers')
})
ctx.on('llm/adapters-updated', () => {
if (!lifetime.signal.aborted) void activeLifecycle?.restore().catch((error) => {
ctx.logger?.warn?.(`[${name}] provider restore retry failed: ${error.message}`)
})
})
const withProviderWrite = async (operation) => {
if (lifetime.signal.aborted) throw new SyncError('plugin-stopped', 'plugin has been unloaded')
if (providerWriteActive) throw new SyncError('sync-busy', 'a provider sync or cleanup is already running; retry when it finishes')
providerWriteActive = true
try {
providerWritePending = (async () => {
await providersReady
if (lifetime.signal.aborted) throw new SyncError('plugin-stopped', 'plugin has been unloaded')
return operation()
})()
return await providerWritePending
} finally {
providerWriteActive = false
}
}
/**
* What the last read of both sources saw.
*
* The card has to answer "how many models does each tier select" for four
* tiers, and that is a pure function of the model list and the catalog. Both
* are already fetched on every sync, so the answer is remembered here rather
* than paid for again on every status read — `describe` is called on a
* cadence, and a docs page per call would be a strange way to draw a card.
*/
let lastFacts = { apiList: [], catalog: [], at: 0 }
let modelInventory
/**
* The most recent account read, so a status read can state the subscription
* without asking the account again. It is a plan id that changes monthly, not
* a balance; the usage panel keeps its own copy fresh and overwrites this.
*/
let lastUsage
let usageReadGeneration = 0
let lastUsageAt = 0
let lastAccountConfig = configurationSignature([resolved().targetApiKeyEnv, resolved().usageBaseURL])
/** One in-flight catalog warm-up, so a burst of status reads shares it. */
let catalogWarmup
/**
* Give a status read the capability catalog when no sync has run yet.
*
* The per-tier model counts are a pure function of the catalog, and the first
* sync is deliberately fifteen seconds after the plugin mounts. Without this,
* anyone who opens the card inside that window sees four tiers whose figures
* have not been read yet, which reads as a card that failed rather than as one
* that is waiting. So the first status read that finds the cache cold fetches
* the single page those counts need — deduped, with its own short ceiling, and
* never at the cost of the status read: a failure here leaves the counts
* unknown, which is exactly what the card already knows how to say.
*/
const ensureCatalog = () => {
if (lastFacts.catalog.length > 0) return Promise.resolve()
if (catalogWarmup !== undefined) return catalogWarmup
const cfg = resolved()
catalogWarmup = (async () => {
const operation = operationSignal(CATALOG_WARMUP_TIMEOUT_MS)
try {
const read = await readCatalog({
catalogURL: cfg.catalogURL,
plan: cfg.plan,
signal: operation.signal,
})
if (!operation.signal.aborted) lastFacts = { ...lastFacts, catalog: read.entries, at: Date.now() }
} catch {
/* an optional enrichment must never fail a status read */
} finally {
operation.release()
catalogWarmup = undefined
}
})()
return catalogWarmup
}
/** The routes this plugin generated for a tier other than the selected one. */
const staleRoutes = () => {
const providers = readPiAiValue(settingsService())?.providers
if (providers === null || typeof providers !== 'object') return []
const plan = resolved().plan
const stale = []
for (const [key, profile] of Object.entries(providers)) {
const parsed = parseGeneratedKey(key)
if (parsed === undefined || parsed.plan === plan) continue
stale.push({
key,
plan: parsed.plan,
slot: parsed.slot,
models: Array.isArray(profile?.models) ? profile.models.length : 0,
})
}
return stale
}
/**
* Read both sources, join them, and write the selected tier's route set.
*
* The write is followed by the sweep, in that order: a prune that ran first
* would delete the routes the account can still use and then leave the user
* with nothing if the write was refused.
*
* @param options - `{ dryRun, sweep }`. `dryRun` reports what would be
* written without writing; `sweep` decides whether the other tiers' routes
* are removed afterwards. The sweep belongs to the button, not to the
* timer: a background tick must never delete another tier's configuration
* behind a reader who is in the middle of switching, and a profile that
* composes two rows to publish two plans would otherwise have each row
* delete the other's routes at startup.
* @returns the sync report the card renders.
*/
const performSync = async ({ dryRun = false, sweep = true, previewToken, diagnose = false } = {}) => {
const settings = settingsService()
const cfg = resolved()
claimPlan(cfg.plan)
const diagnostics = []
await state.ready
const previous = state.value.plans[cfg.plan] ?? {}
const sourceSignature = configurationSignature([cfg.sourceURL, cfg.catalogURL])
// describe() may return live objects; keep the snapshot stable while the
// network requests and preview calculation are in flight.
const currentProviders = structuredClone(readPiAiValue(settings)?.providers ?? {})
const operation = operationSignal(SYNC_TIMEOUT_MS)
const { signal } = operation
try {
signal.throwIfAborted()
let apiList
try {
apiList = await fetchModelList(cfg.sourceURL, signal)
} catch (error) {
throw new SyncError(
SYNC_CODES.fetch,
`could not read the model list from ${cfg.sourceURL}: ${error instanceof Error ? error.message : String(error)}`,
)
}
if (apiList.length === 0) {
throw new SyncError(SYNC_CODES.fetch, `${cfg.sourceURL} answered with no models`)
}
let catalog = []
let catalogAvailable = true
try {
const read = await readCatalog({ catalogURL: cfg.catalogURL, plan: cfg.plan, signal })
catalog = read.entries
if (read.fallbackFrom !== undefined) {
diagnostics.push(`the ${cfg.plan} plan page states no model catalog, so capabilities were read from ${read.source}`)
}
} catch (error) {
catalogAvailable = false
diagnostics.push(`capability catalog could not be read (${error instanceof Error ? error.message : String(error)})`)
if (previous.sourceSignature === sourceSignature && previous.facts?.catalog?.length) {
catalog = previous.facts.catalog
diagnostics.push('using the last successful capability catalog; capabilities and tier filtering are cached')
}
}
signal.throwIfAborted()
if (syncConfigSignature(resolved()) !== syncConfigSignature(cfg)) {
throw new SyncError('config-changed', 'plugin configuration changed during sync; run Create / Update again')
}
// Both payloads are in hand, which is everything the per-tier counts need —
// including on a dry run, where nothing else is written.
lastFacts = { apiList, catalog, at: Date.now() }
const { routes: discovered, diagnostics: built, channels } = buildEntries({
apiList,
catalog,
plan: cfg.plan,
extraIds: cfg.extraIds,
includeReasoningEfforts: cfg.includeReasoningEfforts,
maxContextWindow: cfg.maxContextWindow,
})
diagnostics.push(...built)
// An unavailable capability source is not evidence of a capability being
// removed. Without a snapshot, publish only previously known/explicit ids.
if (!catalogAvailable && catalog.length === 0) {
const known = new Set(Object.values(inventoryFromProviders(currentProviders, cfg.plan)).flat().map((model) => model.id))
for (const id of cfg.extraIds) known.add(id)
for (const route of Object.keys(discovered)) {
const live = new Map((currentProviders[providerKey(cfg.plan, route)]?.models ?? []).map((model) => [model.id, model]))
discovered[route] = discovered[route].filter((model) => known.has(model.id)).map((model) => {
const old = live.get(model.id)
return { ...model, ...old?.input ? { input: old.input } : {},
...old?.reasoningEfforts === undefined ? {} : { reasoningEfforts: old.reasoningEfforts } }
})
}
if (!Object.values(discovered).some((models) => models.length)) {
throw new SyncError('catalog-unavailable', 'capability catalog is unavailable and no trusted model snapshot exists; retry discovery before creating providers')
}
diagnostics.push('only existing or explicitly added models were retained while tier information was unavailable')
}
const preserved = preserveModelOverrides(discovered, currentProviders, previous.inventory, cfg.plan)
const routes = applyModelPreferences(preserved.routes, { preferences: cfg.modelPreferences, onlyFavorites: cfg.onlyFavorites,
maxContextWindow: cfg.maxContextWindow })
const protectedKeys = activeLifecycle?.protectedKeys() ?? []
const routeConfig = { targetApiKeyEnv: cfg.targetApiKeyEnv, targetBaseURL: cfg.targetBaseURL, targetCompat: cfg.targetCompat }
const effectiveRoutes = { ...routes }
for (const route of ROUTE_ORDER) {
const key = providerKey(cfg.plan, route)
const existing = currentProviders[key]
if (!routes[route].length && existing && (protectedKeys.includes(key)
|| hasCustomRouteConfiguration(existing, route, cfg.plan, routeConfig, previous.inventory ?? {}))) {
effectiveRoutes[route] = existing.models ?? []
diagnostics.push(`empty route with custom configuration is retained: ${key}`)
}
}
const diff = diffModels(inventoryFromProviders(currentProviders, cfg.plan), effectiveRoutes)
const token = configurationSignature({ config: syncConfigSignature(cfg), desired: routes, current: currentProviders, protectedKeys })
if (previewToken !== undefined && previewToken !== token) throw new SyncError('preview-stale', 'models or configuration changed since preview; review a new preview before applying')
const counts = Object.fromEntries(
Object.entries(routes).map(([route, entries]) => [providerKey(cfg.plan, route), entries.length]),
)
const report = {
plan: cfg.plan,
dryRun,
catalog: { available: catalogAvailable, entries: catalog.length },
live: apiList.length,
counts,
preview: { token, diff, preserved: preserved.kept },
inventory: Object.entries(discovered).flatMap(([route, models]) => models.map((model) => ({ ...model, route,
selected: routes[route].some((selected) => selected.id === model.id),
published: (inventoryFromProviders(currentProviders, cfg.plan)[route] ?? []).some((entry) => entry.id === model.id),
preference: cfg.modelPreferences[model.id] ?? {} }))),
// Which protocol channels exist is only half the answer; the card also
// needs to say why one is missing, and that is a fact about the live
// list rather than about the write.
channels,
diagnostics,
...lastUsage?.subscription === undefined ? {} : { subscription: lastUsage.subscription },
}
modelInventory = { plan: cfg.plan, models: report.inventory }
if (dryRun) return { ...report, pruned: [] }
const written = await syncPlan({
settings,
plan: cfg.plan,
routes,
allowEmpty: true,
protectedKeys,
previousInventory: previous.inventory ?? {},
expectedProviders: currentProviders,
staleCode: previewToken === undefined ? SYNC_CODES.conflict : 'preview-stale',
cfg: routeConfig,
})
ctx.logger?.info?.(
`[${name}] synced ${cfg.plan}: ${written.routes.map((route) => `${route.key}=${route.count}`).join(', ')}`,
)
try {
await state.update((saved) => {
saved.plans[cfg.plan] = { ...saved.plans[cfg.plan], generated: discovered, inventory: routes, sourceSignature,
facts: catalogAvailable || catalog.length > 0 ? lastFacts : previous.facts, syncedAt: Date.now() }
if (cfg.modelChangeNotifications && previous.generated) {
saved.notifications = appendNotifications(saved.notifications, modelChangeEvents(diffModels(previous.generated, discovered)))
}
return saved
})
} catch (error) { diagnostics.push(`model state could not be saved: ${error.message}`) }
// ── what the routes answer, not just that they were written ────────────
/**
* Probe each route this sync just wrote, with the smallest request its
* protocol accepts.
*
* A settings write can only prove the *config* is internally consistent —
* it cannot prove the URL exists. Every Claude request answered 404 for a
* month while this card said the sync succeeded, because nothing had ever
* put a request on the wire down the route it had just described. The
* request is tiny and the probe reports instead of throwing: a route that
* fails to answer is a fact about the deployment, not a reason to undo a
* write that the harness may still serve.
*/
const carriedRoutes = readPiAiValue(settings)?.providers ?? {}
const probeTargets = written.routes
.map(({ route, key: routeKey }) => {
const carried = carriedRoutes[routeKey]
return {
route,
key: routeKey,
// The base the route actually carries, which is the reader's value
// when they edited one — probing the value this plugin *would* have
// written would report on a configuration nobody is using.
baseURL: carried?.baseURL ?? cfg.targetBaseURL,
// And the credential *that route* names. A route the reader pointed
// at another gateway names another key; sending this plugin's own
// key to that host would hand a credential to a service it was never
// issued for — which the harness itself never does.
apiKeyEnv: typeof carried?.apiKeyEnv === 'string' && carried.apiKeyEnv !== '' ? carried.apiKeyEnv : cfg.targetApiKeyEnv,
model: routes[route]?.[0]?.id,
}
})
.filter((target) => typeof target.model === 'string' && target.model !== '')
let routeChecks
if (diagnose && probeTargets.length > 0 && !signal.aborted) {
const probeOperation = operationSignal(30_000, signal)
try {
const keyed = await Promise.all(probeTargets.map(async (target) => ({
...target,
apiKey: await resolveKey(target.apiKeyEnv),
})))
routeChecks = await probeRoutes({ targets: keyed, signal: probeOperation.signal })
} catch (error) {
// Probing is a report, never a precondition — and the verdict has to
// stay attached to its route, or the card would never mention it.
const detail = error instanceof Error ? error.message : String(error)
routeChecks = probeTargets.map((target) => ({
route: target.route,
key: target.key,
model: target.model,
skipped: 'malformed',
detail,
}))
} finally {
probeOperation.release()
}
}
const checkedRoutes = written.routes.map((entry) => {
const probe = routeChecks?.find((check) => check.key === entry.key)
return probe === undefined ? entry : { ...entry, probe }
})
let pruned = []
if (sweep && cfg.pruneOtherPlans && !signal.aborted && syncConfigSignature(resolved()) === syncConfigSignature(cfg)) {
try {
const sweepResult = await pruneGeneratedRoutes({ settings, plan: cfg.plan, keys: [...claimedKeys],
protectedKeys: activeLifecycle?.protectedKeys() ?? [],
previousInventories: Object.fromEntries(Object.entries(state.value.plans).map(([plan, saved]) => [plan, saved.inventory ?? {}])) })
pruned = sweepResult.removed
if (sweepResult.protected.length > 0) {
diagnostics.push(`left in place because it is in use or has custom configuration: ${sweepResult.protected.join(', ')}`)
}
if (pruned.length > 0) {
ctx.logger?.info?.(`[${name}] removed the routes of the previous tier: ${pruned.join(', ')}`)
// The profile can point at a route by name; deleting it would then
// take the reader's default model with it, silently.
const referenced = sectionsReferencing(settings, pruned)
if (referenced.length > 0) {
diagnostics.push(`still refers to a removed provider, and has to be updated: ${referenced.join('; ')}`)
}
}
} catch (error) {
// A failed sweep must not turn a successful sync into a failure: the
// routes the user needs are already written.
diagnostics.push(`the routes of the previous tier could not be removed (${error instanceof Error ? error.message : String(error)})`)
}
}
return { ...report, diagnostics, routes: checkedRoutes, skipped: written.skipped, pruned }
} finally {
operation.release()
}
}
const syncOnce = (options) => withProviderWrite(() => performSync(options))
// ── usage ──────────────────────────────────────────────────────────────────
/**
* Read the account surface with the configured key.
*
* @param base - optional API root override from the card.
* @param signal - optional caller cancellation.
*/
const readUsage = async (base, signal) => {
const generation = ++usageReadGeneration
// A new read may belong to another credential or account. Never retain an
// old subscription when this read fails or omits subscription information.
lastUsage = undefined
const cfg = resolved()
const root = typeof base === 'string' && base.trim() !== '' ? base.trim() : cfg.usageBaseURL
if (!URL.canParse(root) || !['http:', 'https:'].includes(new URL(root).protocol)) {
throw new SyncError('usage-misconfigured', `usage base ${JSON.stringify(root)} is not a valid URL`)
}
const operation = operationSignal(60_000, signal)
try {
const key = await abortable(resolveKey(cfg.targetApiKeyEnv), operation.signal)
if (key === undefined) {
throw new SyncError(
SYNC_CODES.noKey,
`no "${cfg.targetApiKeyEnv}" credential is configured; store it in Settings → Models or export it before launch`,
)
}
operation.signal.throwIfAborted()
const report = await fetchUsageReport(key, root, operation.signal)
if (generation !== usageReadGeneration || operation.signal.aborted
|| cfg.targetApiKeyEnv !== resolved().targetApiKeyEnv || cfg.usageBaseURL !== resolved().usageBaseURL
|| key !== await abortable(resolveKey(cfg.targetApiKeyEnv), operation.signal)) {
throw new SyncError('account-changed', 'account configuration changed while reading usage; refresh the current account')
}
// Remembered for the status read, which states the account's own tier
// beside the configured one without asking the account a second time.
if (root === resolved().usageBaseURL) {
lastUsage = report
lastUsageAt = Date.now()
const identity = configurationSignature([root, cfg.targetApiKeyEnv, key])
const evaluated = evaluateAlerts(report, cfg.alertThresholds, state.value.lastAccountIdentity === identity ? state.value.alertState : {})
await state.update((saved) => ({ ...saved, lastAccountIdentity: identity, alertState: evaluated.state,
notifications: appendNotifications(saved.notifications, evaluated.events) })).catch((error) => {
ctx.logger?.warn?.(`[${name}] could not persist quota notifications: ${error.message}`)
})
}
return report
} finally {
operation.release()
}
}
// ── web search ─────────────────────────────────────────────────────────────
let applySearchSelection = () => {}
/** Re-applied on every volatile update, like the search selection. */
let applyUsageTool = () => {}
let searchReport = () => ({ registered: false, enabled: false, selected: SEARCH_PROVIDER_ID })
ctx.inject(['web'], (webCtx) => {
const web = webCtx.web
const provider = makeSearchProvider({
signal: lifetime.signal,
isEnabled: () => resolved().webSearch,
apiBase: () => resolved().usageBaseURL,
keyEnv: () => resolved().targetApiKeyEnv,
resolveKey,
})
const disposeProvider = web.registerSearchProvider(provider)
const select = makeSelectionController(web)
const sync = () => {
try {
select(resolved().webSearch === true)
} catch {
/* a runtime whose selection cannot be written keeps its own choice */
}
}
applySearchSelection = sync
searchReport = () => ({
registered: true,
enabled: resolved().webSearch === true,
selected: web.searchProviderId,
})
sync()
// The web service registers a provider on its own fiber, so the returned
// disposer is the only thing that unregisters it when this fiber unloads.
webCtx.effect(() => () => {
// A dependency can unload while the plugin and volatile listener remain.
// Forget this injection's callbacks before touching the disappearing service.
if (applySearchSelection === sync) {
applySearchSelection = () => {}
searchReport = () => ({ registered: false, enabled: false, selected: undefined })
}
try {
select(false)
} catch { /* the web runtime may already be gone */ }
disposeProvider()
}, 'dsh-commandcode-goat: web search')
})
// ── live configuration ─────────────────────────────────────────────────────
/**
* A volatile edit does not remount the plugin; it re-resolves the config and
* fires this event. Nothing else re-reads the fields — they are live
* references — so the two things derived from them that live in *another*
* service have to be re-applied here: the search provider selection, and the
* registration of the usage tool.
*/
ctx.on('loader/volatile-update', () => {
claimPlan(resolved().plan)
const accountConfig = configurationSignature([resolved().targetApiKeyEnv, resolved().usageBaseURL])
if (accountConfig !== lastAccountConfig) {
lastUsage = undefined
lastUsageAt = 0
usageReadGeneration += 1
lastAccountConfig = accountConfig
}
applySearchSelection()
applyUsageTool()
})
// ── auto-sync ──────────────────────────────────────────────────────────────
/**
* The timer runs whether or not the toggle is currently on, and each tick
* decides for itself.
*
* That is not incidental. Both the toggle and the interval are volatile
* fields: the card flips them without remounting the plugin, so a value
* captured when this effect ran would keep syncing — or keep refusing to —
* against the user's decision until the profile restarted. Reading them at
* the moment they are used is what makes the switch mean something the first
* time it is touched. An off tick costs one comparison.
*/
ctx.effect(() => {