dsh-plugin-subscriptions 0.3.0 → 0.3.1
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 +247 -27
- package/lib/providers/catalog-store.d.ts +37 -0
- package/lib/providers/catalog-store.js +167 -0
- package/lib/providers/codex.d.ts +13 -1
- package/lib/providers/codex.js +26 -10
- package/lib/providers/common.d.ts +47 -5
- package/lib/providers/common.js +74 -11
- package/lib/providers/grok.d.ts +14 -1
- package/lib/providers/grok.js +25 -8
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -678,14 +678,26 @@ var TokenManager = class {
|
|
|
678
678
|
/** How long a discovered catalog is trusted before re-fetching. */
|
|
679
679
|
const DISCOVERY_TTL_MS = 5 * 6e4;
|
|
680
680
|
/**
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
681
|
+
* Cache for one provider's discovered model catalog. The TTL only decides
|
|
682
|
+
* when to REFRESH; it never makes the cache forget: capability metadata
|
|
683
|
+
* (reasoning efforts) must stay stable for a session that selected an effort,
|
|
684
|
+
* or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
|
|
685
|
+
* cache goes stale. `listModels` awaits freshness via {@link get};
|
|
686
|
+
* `resolveModel` uses {@link resolve}, which serves the last-known catalog
|
|
687
|
+
* while a stale entry refreshes in the background, and only awaits the fetch
|
|
688
|
+
* when nothing is known yet. An optional {@link CatalogPersistence} seeds the
|
|
689
|
+
* last-known state across restarts and receives every successful fetch. A 401
|
|
690
|
+
* during a fetch must call {@link invalidate}.
|
|
685
691
|
*/
|
|
686
692
|
var ModelCatalogCache = class {
|
|
687
693
|
entry;
|
|
688
|
-
|
|
694
|
+
inflight;
|
|
695
|
+
/** Settles once the persisted snapshot (when any) has been considered. */
|
|
696
|
+
seeded;
|
|
697
|
+
/** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
|
|
698
|
+
seedDisabled = false;
|
|
699
|
+
constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
|
|
700
|
+
this.persistence = persistence;
|
|
689
701
|
this.ttlMs = ttlMs;
|
|
690
702
|
}
|
|
691
703
|
/**
|
|
@@ -696,27 +708,200 @@ var ModelCatalogCache = class {
|
|
|
696
708
|
if (this.entry === void 0 || Date.now() - this.entry.at >= this.ttlMs) return void 0;
|
|
697
709
|
return this.entry.models;
|
|
698
710
|
}
|
|
711
|
+
/** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
|
|
712
|
+
ensureSeeded() {
|
|
713
|
+
if (this.persistence === void 0) return Promise.resolve();
|
|
714
|
+
this.seeded ??= this.persistence.load().then((snapshot) => {
|
|
715
|
+
if (snapshot !== void 0 && this.entry === void 0 && !this.seedDisabled) this.entry = snapshot;
|
|
716
|
+
}, () => void 0);
|
|
717
|
+
return this.seeded;
|
|
718
|
+
}
|
|
719
|
+
/** Run (or join) the single in-flight fetch, updating memory and disk on success. */
|
|
720
|
+
refresh(fetcher) {
|
|
721
|
+
this.inflight ??= fetcher().then((models) => {
|
|
722
|
+
const snapshot = {
|
|
723
|
+
at: Date.now(),
|
|
724
|
+
models
|
|
725
|
+
};
|
|
726
|
+
this.entry = snapshot;
|
|
727
|
+
this.persistence?.save(snapshot).catch(() => void 0);
|
|
728
|
+
return models;
|
|
729
|
+
}).finally(() => {
|
|
730
|
+
this.inflight = void 0;
|
|
731
|
+
});
|
|
732
|
+
return this.inflight;
|
|
733
|
+
}
|
|
699
734
|
/**
|
|
700
735
|
* Return the cached catalog when fresh, otherwise fetch and cache it.
|
|
701
736
|
* @param fetcher - performs the provider's model-list request.
|
|
702
737
|
* @returns the discovered models.
|
|
738
|
+
* @throws the fetcher's failure (the `listModels` caller warns and falls back).
|
|
703
739
|
*/
|
|
704
740
|
async get(fetcher) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
741
|
+
await this.ensureSeeded();
|
|
742
|
+
return this.cached() ?? this.refresh(fetcher);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* The models for capability resolution. A fresh cache answers directly; a
|
|
746
|
+
* stale one answers immediately from the last-known catalog while a
|
|
747
|
+
* background refresh runs (a mid-conversation `resolveModel` must neither
|
|
748
|
+
* block on nor fail with the network); a cold cache awaits one fetch.
|
|
749
|
+
* @param fetcher - performs the provider's model-list request.
|
|
750
|
+
* @returns the models, or `undefined` when nothing is known (the caller
|
|
751
|
+
* falls back to its static metadata). Never throws.
|
|
752
|
+
*/
|
|
753
|
+
async resolve(fetcher) {
|
|
754
|
+
await this.ensureSeeded();
|
|
755
|
+
const fresh = this.cached();
|
|
756
|
+
if (fresh !== void 0) return fresh;
|
|
757
|
+
const known = this.entry?.models;
|
|
758
|
+
if (known !== void 0) {
|
|
759
|
+
this.refresh(fetcher).catch(() => void 0);
|
|
760
|
+
return known;
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
return await this.refresh(fetcher);
|
|
764
|
+
} catch {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
713
767
|
}
|
|
714
768
|
/** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
|
|
715
769
|
invalidate() {
|
|
716
770
|
this.entry = void 0;
|
|
771
|
+
this.seedDisabled = true;
|
|
772
|
+
this.persistence?.clear().catch(() => void 0);
|
|
717
773
|
}
|
|
718
774
|
};
|
|
719
775
|
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/providers/catalog-store.ts
|
|
778
|
+
/**
|
|
779
|
+
* Absolute path of the catalog store file.
|
|
780
|
+
* @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
|
|
781
|
+
*/
|
|
782
|
+
function modelsFilePath() {
|
|
783
|
+
return dshHomePath("plugins", "subscriptions", "models.json");
|
|
784
|
+
}
|
|
785
|
+
/** Validate one persisted reasoning block, or undefined when malformed. */
|
|
786
|
+
function sanitizeReasoning(value) {
|
|
787
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
788
|
+
const raw = value;
|
|
789
|
+
if (!Array.isArray(raw.efforts) || raw.efforts.length === 0) return void 0;
|
|
790
|
+
const seen = /* @__PURE__ */ new Set();
|
|
791
|
+
const efforts = [];
|
|
792
|
+
for (const entry of raw.efforts) {
|
|
793
|
+
if (typeof entry !== "object" || entry === null) return void 0;
|
|
794
|
+
const effort = entry;
|
|
795
|
+
if (typeof effort.id !== "string" || effort.id.length === 0 || typeof effort.name !== "string" || effort.name.length === 0 || effort.description !== void 0 && typeof effort.description !== "string" || seen.has(effort.id)) return void 0;
|
|
796
|
+
seen.add(effort.id);
|
|
797
|
+
efforts.push({
|
|
798
|
+
id: ReasoningEffortId(effort.id),
|
|
799
|
+
name: effort.name,
|
|
800
|
+
...effort.description === void 0 ? {} : { description: effort.description }
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (raw.defaultEffort !== void 0 && (typeof raw.defaultEffort !== "string" || !seen.has(raw.defaultEffort))) return void 0;
|
|
804
|
+
return {
|
|
805
|
+
efforts,
|
|
806
|
+
...raw.defaultEffort === void 0 ? {} : { defaultEffort: ReasoningEffortId(raw.defaultEffort) }
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/** Validate one persisted model, or undefined when malformed. */
|
|
810
|
+
function sanitizeModel(value) {
|
|
811
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
812
|
+
const raw = value;
|
|
813
|
+
if (typeof raw.id !== "string" || raw.id.length === 0 || typeof raw.name !== "string" || raw.name.length === 0 || raw.description !== void 0 && typeof raw.description !== "string" || raw.contextWindow !== void 0 && (typeof raw.contextWindow !== "number" || !Number.isInteger(raw.contextWindow) || raw.contextWindow <= 0) || raw.priority !== void 0 && (typeof raw.priority !== "number" || !Number.isFinite(raw.priority))) return void 0;
|
|
814
|
+
const reasoning = raw.reasoning === void 0 ? void 0 : sanitizeReasoning(raw.reasoning);
|
|
815
|
+
if (raw.reasoning !== void 0 && reasoning === void 0) return void 0;
|
|
816
|
+
return {
|
|
817
|
+
id: raw.id,
|
|
818
|
+
name: raw.name,
|
|
819
|
+
...raw.description === void 0 ? {} : { description: raw.description },
|
|
820
|
+
...raw.contextWindow === void 0 ? {} : { contextWindow: raw.contextWindow },
|
|
821
|
+
...raw.priority === void 0 ? {} : { priority: raw.priority },
|
|
822
|
+
...reasoning === void 0 ? {} : { reasoning }
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Validate one persisted snapshot. Strict: any malformed field drops the
|
|
827
|
+
* whole snapshot rather than repairing it — the next successful discovery
|
|
828
|
+
* rewrites the entry anyway.
|
|
829
|
+
* @param value - the raw per-provider file entry.
|
|
830
|
+
* @returns the validated snapshot, or undefined when unusable.
|
|
831
|
+
*/
|
|
832
|
+
function sanitizeSnapshot(value) {
|
|
833
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
834
|
+
const raw = value;
|
|
835
|
+
if (typeof raw.at !== "number" || !Number.isFinite(raw.at)) return void 0;
|
|
836
|
+
if (!Array.isArray(raw.models) || raw.models.length === 0) return void 0;
|
|
837
|
+
const seen = /* @__PURE__ */ new Set();
|
|
838
|
+
const models = [];
|
|
839
|
+
for (const entry of raw.models) {
|
|
840
|
+
const model = sanitizeModel(entry);
|
|
841
|
+
if (model === void 0 || seen.has(model.id)) return void 0;
|
|
842
|
+
seen.add(model.id);
|
|
843
|
+
models.push(model);
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
at: raw.at,
|
|
847
|
+
models
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
/** Read the whole file; missing or unparsable reads as an empty cache. */
|
|
851
|
+
async function readCatalogFile(path) {
|
|
852
|
+
let text;
|
|
853
|
+
try {
|
|
854
|
+
text = await readFile(path, "utf8");
|
|
855
|
+
} catch {
|
|
856
|
+
return {};
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
const parsed = JSON.parse(text);
|
|
860
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
861
|
+
return parsed;
|
|
862
|
+
} catch {
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Persist the whole file atomically (tmp file + rename). */
|
|
867
|
+
async function writeCatalogFile(store, path) {
|
|
868
|
+
await mkdir(dirname(path), { recursive: true });
|
|
869
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
870
|
+
try {
|
|
871
|
+
await writeFile(tmp, JSON.stringify(store, null, 2));
|
|
872
|
+
await rename(tmp, path);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
await rm(tmp, { force: true });
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Build the durable half of one provider's catalog cache over the shared
|
|
880
|
+
* models.json file (concurrent writers are last-writer-wins, acceptable for
|
|
881
|
+
* a cache).
|
|
882
|
+
* @param provider - the provider route keying the file entry.
|
|
883
|
+
* @param path - store file path; defaults to {@link modelsFilePath}.
|
|
884
|
+
* @returns the persistence hooks for {@link ModelCatalogCache}.
|
|
885
|
+
*/
|
|
886
|
+
function catalogStore(provider, path = modelsFilePath()) {
|
|
887
|
+
return {
|
|
888
|
+
async load() {
|
|
889
|
+
return sanitizeSnapshot((await readCatalogFile(path))[provider]);
|
|
890
|
+
},
|
|
891
|
+
async save(snapshot) {
|
|
892
|
+
const store = await readCatalogFile(path);
|
|
893
|
+
store[provider] = snapshot;
|
|
894
|
+
await writeCatalogFile(store, path);
|
|
895
|
+
},
|
|
896
|
+
async clear() {
|
|
897
|
+
const store = await readCatalogFile(path);
|
|
898
|
+
if (store[provider] === void 0) return;
|
|
899
|
+
delete store[provider];
|
|
900
|
+
await writeCatalogFile(store, path);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
720
905
|
//#endregion
|
|
721
906
|
//#region src/auth/jwt.ts
|
|
722
907
|
/** Minimal JWT payload decoding for claims extraction (no signature verification). */
|
|
@@ -1430,10 +1615,15 @@ async function fetchCodexModels(session, fetchFn = fetch) {
|
|
|
1430
1615
|
}
|
|
1431
1616
|
/** Codex wire adapter: one instance serves the `codex` provider route. */
|
|
1432
1617
|
var CodexAdapter = class extends LlmAdapter {
|
|
1433
|
-
catalog
|
|
1618
|
+
catalog;
|
|
1434
1619
|
constructor(options) {
|
|
1435
1620
|
super();
|
|
1436
1621
|
this.options = options;
|
|
1622
|
+
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
1623
|
+
}
|
|
1624
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
1625
|
+
async fetchCatalog() {
|
|
1626
|
+
return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
|
|
1437
1627
|
}
|
|
1438
1628
|
providerInfo(provider) {
|
|
1439
1629
|
return {
|
|
@@ -1453,7 +1643,7 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
1453
1643
|
if (await this.options.tokens.peek() === void 0) return [];
|
|
1454
1644
|
if (!this.options.discovery) return this.staticModels(provider);
|
|
1455
1645
|
try {
|
|
1456
|
-
return (await this.catalog.get(
|
|
1646
|
+
return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
|
|
1457
1647
|
provider,
|
|
1458
1648
|
id: model.id,
|
|
1459
1649
|
name: model.name,
|
|
@@ -1467,10 +1657,21 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
1467
1657
|
return this.staticModels(provider);
|
|
1468
1658
|
}
|
|
1469
1659
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1660
|
+
/**
|
|
1661
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
1662
|
+
* stale-while-revalidate path so capability metadata stays stable across a
|
|
1663
|
+
* long conversation: a discovered-only effort (one missing from the static
|
|
1664
|
+
* CODEX_EFFORTS list) selected by the user must not vanish — and fail the
|
|
1665
|
+
* call — just because the TTL lapsed mid-turn.
|
|
1666
|
+
*/
|
|
1667
|
+
async discovered(model) {
|
|
1668
|
+
if (!this.options.discovery) return void 0;
|
|
1669
|
+
return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
|
|
1670
|
+
}
|
|
1671
|
+
async resolveModel(provider, model) {
|
|
1672
|
+
const discovered = await this.discovered(model);
|
|
1472
1673
|
const configured = this.options.models.find((entry) => entry.id === model);
|
|
1473
|
-
return
|
|
1674
|
+
return {
|
|
1474
1675
|
provider,
|
|
1475
1676
|
id: model,
|
|
1476
1677
|
name: discovered?.name ?? configured?.name ?? model,
|
|
@@ -1482,7 +1683,7 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
1482
1683
|
efforts: CODEX_EFFORTS,
|
|
1483
1684
|
defaultEffort: CODEX_DEFAULT_EFFORT
|
|
1484
1685
|
}
|
|
1485
|
-
}
|
|
1686
|
+
};
|
|
1486
1687
|
}
|
|
1487
1688
|
async *stream(options) {
|
|
1488
1689
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
@@ -2538,10 +2739,15 @@ async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
|
|
|
2538
2739
|
}
|
|
2539
2740
|
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
2540
2741
|
var GrokAdapter = class extends LlmAdapter {
|
|
2541
|
-
catalog
|
|
2742
|
+
catalog;
|
|
2542
2743
|
constructor(options) {
|
|
2543
2744
|
super();
|
|
2544
2745
|
this.options = options;
|
|
2746
|
+
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
2747
|
+
}
|
|
2748
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
2749
|
+
async fetchCatalog() {
|
|
2750
|
+
return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn);
|
|
2545
2751
|
}
|
|
2546
2752
|
providerInfo(provider) {
|
|
2547
2753
|
return {
|
|
@@ -2561,7 +2767,7 @@ var GrokAdapter = class extends LlmAdapter {
|
|
|
2561
2767
|
if (await this.options.tokens.peek() === void 0) return [];
|
|
2562
2768
|
if (!this.options.discovery) return this.staticModels(provider);
|
|
2563
2769
|
try {
|
|
2564
|
-
return (await this.catalog.get(
|
|
2770
|
+
return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
|
|
2565
2771
|
provider,
|
|
2566
2772
|
id: model.id,
|
|
2567
2773
|
name: model.name,
|
|
@@ -2575,10 +2781,22 @@ var GrokAdapter = class extends LlmAdapter {
|
|
|
2575
2781
|
return this.staticModels(provider);
|
|
2576
2782
|
}
|
|
2577
2783
|
}
|
|
2578
|
-
|
|
2579
|
-
|
|
2784
|
+
/**
|
|
2785
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
2786
|
+
* stale-while-revalidate path: capability metadata must stay stable across
|
|
2787
|
+
* a long conversation — a session that selected a reasoning effort calls
|
|
2788
|
+
* this on EVERY step, and forgetting the efforts just because the TTL
|
|
2789
|
+
* lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
|
|
2790
|
+
* before provider I/O.
|
|
2791
|
+
*/
|
|
2792
|
+
async discovered(model) {
|
|
2793
|
+
if (!this.options.discovery) return void 0;
|
|
2794
|
+
return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
|
|
2795
|
+
}
|
|
2796
|
+
async resolveModel(provider, model) {
|
|
2797
|
+
const discovered = await this.discovered(model);
|
|
2580
2798
|
const configured = this.options.models.find((entry) => entry.id === model);
|
|
2581
|
-
return
|
|
2799
|
+
return {
|
|
2582
2800
|
provider,
|
|
2583
2801
|
id: model,
|
|
2584
2802
|
name: discovered?.name ?? configured?.name ?? model,
|
|
@@ -2587,7 +2805,7 @@ var GrokAdapter = class extends LlmAdapter {
|
|
|
2587
2805
|
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
|
|
2588
2806
|
defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
|
|
2589
2807
|
...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
|
|
2590
|
-
}
|
|
2808
|
+
};
|
|
2591
2809
|
}
|
|
2592
2810
|
async *stream(options) {
|
|
2593
2811
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
@@ -3303,7 +3521,8 @@ function apply(ctx, config) {
|
|
|
3303
3521
|
tokens,
|
|
3304
3522
|
discovery: !overridden.has("codex"),
|
|
3305
3523
|
onWarn,
|
|
3306
|
-
resolveAttachments
|
|
3524
|
+
resolveAttachments,
|
|
3525
|
+
catalogStore: catalogStore("codex")
|
|
3307
3526
|
})));
|
|
3308
3527
|
break;
|
|
3309
3528
|
}
|
|
@@ -3350,7 +3569,8 @@ function apply(ctx, config) {
|
|
|
3350
3569
|
tokens,
|
|
3351
3570
|
discovery: !overridden.has("grok"),
|
|
3352
3571
|
onWarn,
|
|
3353
|
-
resolveAttachments
|
|
3572
|
+
resolveAttachments,
|
|
3573
|
+
catalogStore: catalogStore("grok")
|
|
3354
3574
|
})));
|
|
3355
3575
|
break;
|
|
3356
3576
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk discovered-model-catalog cache at
|
|
3
|
+
* `~/.dsh/plugins/subscriptions/models.json` — the durable half of each
|
|
4
|
+
* provider's {@link ModelCatalogCache}. One entry per provider: the last
|
|
5
|
+
* successfully discovered catalog with its fetch time, so capability metadata
|
|
6
|
+
* (reasoning efforts) survives restarts and network failures.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the auth store, this file is a cache: a missing, corrupt, or
|
|
9
|
+
* malformed file silently reads as absent, because the next successful
|
|
10
|
+
* discovery rewrites it. Loads are strictly validated — a malformed entry
|
|
11
|
+
* passed through `resolveModel` would make the harness's metadata validation
|
|
12
|
+
* throw on every call, which is worse than having no fallback at all.
|
|
13
|
+
*/
|
|
14
|
+
import type { ProviderId } from '../auth/store.js';
|
|
15
|
+
import type { CatalogPersistence, CatalogSnapshot } from './common.js';
|
|
16
|
+
/**
|
|
17
|
+
* Absolute path of the catalog store file.
|
|
18
|
+
* @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function modelsFilePath(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Validate one persisted snapshot. Strict: any malformed field drops the
|
|
23
|
+
* whole snapshot rather than repairing it — the next successful discovery
|
|
24
|
+
* rewrites the entry anyway.
|
|
25
|
+
* @param value - the raw per-provider file entry.
|
|
26
|
+
* @returns the validated snapshot, or undefined when unusable.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sanitizeSnapshot(value: unknown): CatalogSnapshot | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Build the durable half of one provider's catalog cache over the shared
|
|
31
|
+
* models.json file (concurrent writers are last-writer-wins, acceptable for
|
|
32
|
+
* a cache).
|
|
33
|
+
* @param provider - the provider route keying the file entry.
|
|
34
|
+
* @param path - store file path; defaults to {@link modelsFilePath}.
|
|
35
|
+
* @returns the persistence hooks for {@link ModelCatalogCache}.
|
|
36
|
+
*/
|
|
37
|
+
export declare function catalogStore(provider: ProviderId, path?: string): CatalogPersistence;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk discovered-model-catalog cache at
|
|
3
|
+
* `~/.dsh/plugins/subscriptions/models.json` — the durable half of each
|
|
4
|
+
* provider's {@link ModelCatalogCache}. One entry per provider: the last
|
|
5
|
+
* successfully discovered catalog with its fetch time, so capability metadata
|
|
6
|
+
* (reasoning efforts) survives restarts and network failures.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the auth store, this file is a cache: a missing, corrupt, or
|
|
9
|
+
* malformed file silently reads as absent, because the next successful
|
|
10
|
+
* discovery rewrites it. Loads are strictly validated — a malformed entry
|
|
11
|
+
* passed through `resolveModel` would make the harness's metadata validation
|
|
12
|
+
* throw on every call, which is worse than having no fallback at all.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
17
|
+
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
18
|
+
/**
|
|
19
|
+
* Absolute path of the catalog store file.
|
|
20
|
+
* @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
|
|
21
|
+
*/
|
|
22
|
+
export function modelsFilePath() {
|
|
23
|
+
return dshHomePath('plugins', 'subscriptions', 'models.json');
|
|
24
|
+
}
|
|
25
|
+
/** Validate one persisted reasoning block, or undefined when malformed. */
|
|
26
|
+
function sanitizeReasoning(value) {
|
|
27
|
+
if (typeof value !== 'object' || value === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const raw = value;
|
|
30
|
+
if (!Array.isArray(raw.efforts) || raw.efforts.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const efforts = [];
|
|
34
|
+
for (const entry of raw.efforts) {
|
|
35
|
+
if (typeof entry !== 'object' || entry === null)
|
|
36
|
+
return undefined;
|
|
37
|
+
const effort = entry;
|
|
38
|
+
if (typeof effort.id !== 'string' || effort.id.length === 0
|
|
39
|
+
|| typeof effort.name !== 'string' || effort.name.length === 0
|
|
40
|
+
|| (effort.description !== undefined && typeof effort.description !== 'string')
|
|
41
|
+
|| seen.has(effort.id))
|
|
42
|
+
return undefined;
|
|
43
|
+
seen.add(effort.id);
|
|
44
|
+
efforts.push({
|
|
45
|
+
id: ReasoningEffortId(effort.id),
|
|
46
|
+
name: effort.name,
|
|
47
|
+
...effort.description === undefined ? {} : { description: effort.description },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (raw.defaultEffort !== undefined
|
|
51
|
+
&& (typeof raw.defaultEffort !== 'string' || !seen.has(raw.defaultEffort)))
|
|
52
|
+
return undefined;
|
|
53
|
+
return {
|
|
54
|
+
efforts,
|
|
55
|
+
...raw.defaultEffort === undefined ? {} : { defaultEffort: ReasoningEffortId(raw.defaultEffort) },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Validate one persisted model, or undefined when malformed. */
|
|
59
|
+
function sanitizeModel(value) {
|
|
60
|
+
if (typeof value !== 'object' || value === null)
|
|
61
|
+
return undefined;
|
|
62
|
+
const raw = value;
|
|
63
|
+
if (typeof raw.id !== 'string' || raw.id.length === 0
|
|
64
|
+
|| typeof raw.name !== 'string' || raw.name.length === 0
|
|
65
|
+
|| (raw.description !== undefined && typeof raw.description !== 'string')
|
|
66
|
+
|| (raw.contextWindow !== undefined
|
|
67
|
+
&& (typeof raw.contextWindow !== 'number' || !Number.isInteger(raw.contextWindow) || raw.contextWindow <= 0))
|
|
68
|
+
|| (raw.priority !== undefined
|
|
69
|
+
&& (typeof raw.priority !== 'number' || !Number.isFinite(raw.priority))))
|
|
70
|
+
return undefined;
|
|
71
|
+
const reasoning = raw.reasoning === undefined ? undefined : sanitizeReasoning(raw.reasoning);
|
|
72
|
+
if (raw.reasoning !== undefined && reasoning === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
return {
|
|
75
|
+
id: raw.id,
|
|
76
|
+
name: raw.name,
|
|
77
|
+
...raw.description === undefined ? {} : { description: raw.description },
|
|
78
|
+
...raw.contextWindow === undefined ? {} : { contextWindow: raw.contextWindow },
|
|
79
|
+
...raw.priority === undefined ? {} : { priority: raw.priority },
|
|
80
|
+
...reasoning === undefined ? {} : { reasoning },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Validate one persisted snapshot. Strict: any malformed field drops the
|
|
85
|
+
* whole snapshot rather than repairing it — the next successful discovery
|
|
86
|
+
* rewrites the entry anyway.
|
|
87
|
+
* @param value - the raw per-provider file entry.
|
|
88
|
+
* @returns the validated snapshot, or undefined when unusable.
|
|
89
|
+
*/
|
|
90
|
+
export function sanitizeSnapshot(value) {
|
|
91
|
+
if (typeof value !== 'object' || value === null)
|
|
92
|
+
return undefined;
|
|
93
|
+
const raw = value;
|
|
94
|
+
if (typeof raw.at !== 'number' || !Number.isFinite(raw.at))
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!Array.isArray(raw.models) || raw.models.length === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const models = [];
|
|
100
|
+
for (const entry of raw.models) {
|
|
101
|
+
const model = sanitizeModel(entry);
|
|
102
|
+
if (model === undefined || seen.has(model.id))
|
|
103
|
+
return undefined;
|
|
104
|
+
seen.add(model.id);
|
|
105
|
+
models.push(model);
|
|
106
|
+
}
|
|
107
|
+
return { at: raw.at, models };
|
|
108
|
+
}
|
|
109
|
+
/** Read the whole file; missing or unparsable reads as an empty cache. */
|
|
110
|
+
async function readCatalogFile(path) {
|
|
111
|
+
let text;
|
|
112
|
+
try {
|
|
113
|
+
text = await readFile(path, 'utf8');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(text);
|
|
120
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
121
|
+
return {};
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Persist the whole file atomically (tmp file + rename). */
|
|
129
|
+
async function writeCatalogFile(store, path) {
|
|
130
|
+
await mkdir(dirname(path), { recursive: true });
|
|
131
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
132
|
+
try {
|
|
133
|
+
await writeFile(tmp, JSON.stringify(store, null, 2));
|
|
134
|
+
await rename(tmp, path);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
await rm(tmp, { force: true });
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Build the durable half of one provider's catalog cache over the shared
|
|
143
|
+
* models.json file (concurrent writers are last-writer-wins, acceptable for
|
|
144
|
+
* a cache).
|
|
145
|
+
* @param provider - the provider route keying the file entry.
|
|
146
|
+
* @param path - store file path; defaults to {@link modelsFilePath}.
|
|
147
|
+
* @returns the persistence hooks for {@link ModelCatalogCache}.
|
|
148
|
+
*/
|
|
149
|
+
export function catalogStore(provider, path = modelsFilePath()) {
|
|
150
|
+
return {
|
|
151
|
+
async load() {
|
|
152
|
+
return sanitizeSnapshot((await readCatalogFile(path))[provider]);
|
|
153
|
+
},
|
|
154
|
+
async save(snapshot) {
|
|
155
|
+
const store = await readCatalogFile(path);
|
|
156
|
+
store[provider] = snapshot;
|
|
157
|
+
await writeCatalogFile(store, path);
|
|
158
|
+
},
|
|
159
|
+
async clear() {
|
|
160
|
+
const store = await readCatalogFile(path);
|
|
161
|
+
if (store[provider] === undefined)
|
|
162
|
+
return;
|
|
163
|
+
delete store[provider];
|
|
164
|
+
await writeCatalogFile(store, path);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
package/lib/providers/codex.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { FlowSpec } from '../auth/oauth-flow.js';
|
|
|
9
9
|
import type { CodexSession } from '../auth/store.js';
|
|
10
10
|
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
11
11
|
import { TokenManager } from './common.js';
|
|
12
|
-
import type { DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
|
|
12
|
+
import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
|
|
13
13
|
export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
14
14
|
export declare const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
15
15
|
export declare const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
@@ -93,15 +93,27 @@ export interface CodexAdapterOptions {
|
|
|
93
93
|
fetchFn?: FetchFn;
|
|
94
94
|
/** Resolve the attachment service per request; absent means image requests fail loudly. */
|
|
95
95
|
resolveAttachments?: () => AttachmentStore | undefined;
|
|
96
|
+
/** Durable catalog store seeding capability metadata across restarts. */
|
|
97
|
+
catalogStore?: CatalogPersistence;
|
|
96
98
|
}
|
|
97
99
|
/** Codex wire adapter: one instance serves the `codex` provider route. */
|
|
98
100
|
export declare class CodexAdapter extends LlmAdapter {
|
|
99
101
|
private readonly options;
|
|
100
102
|
private readonly catalog;
|
|
101
103
|
constructor(options: CodexAdapterOptions);
|
|
104
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
105
|
+
private fetchCatalog;
|
|
102
106
|
providerInfo(provider: string): LlmProviderInfo;
|
|
103
107
|
private staticModels;
|
|
104
108
|
listModels(provider: string): Promise<readonly LlmModelInfo[]>;
|
|
109
|
+
/**
|
|
110
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
111
|
+
* stale-while-revalidate path so capability metadata stays stable across a
|
|
112
|
+
* long conversation: a discovered-only effort (one missing from the static
|
|
113
|
+
* CODEX_EFFORTS list) selected by the user must not vanish — and fail the
|
|
114
|
+
* call — just because the TTL lapsed mid-turn.
|
|
115
|
+
*/
|
|
116
|
+
private discovered;
|
|
105
117
|
resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
106
118
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
107
119
|
private request;
|
package/lib/providers/codex.js
CHANGED
|
@@ -327,10 +327,15 @@ export async function fetchCodexModels(session, fetchFn = fetch) {
|
|
|
327
327
|
/** Codex wire adapter: one instance serves the `codex` provider route. */
|
|
328
328
|
export class CodexAdapter extends LlmAdapter {
|
|
329
329
|
options;
|
|
330
|
-
catalog
|
|
330
|
+
catalog;
|
|
331
331
|
constructor(options) {
|
|
332
332
|
super();
|
|
333
333
|
this.options = options;
|
|
334
|
+
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
335
|
+
}
|
|
336
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
337
|
+
async fetchCatalog() {
|
|
338
|
+
return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
|
|
334
339
|
}
|
|
335
340
|
providerInfo(provider) {
|
|
336
341
|
return { id: provider, name: 'ChatGPT (Codex)' };
|
|
@@ -354,7 +359,7 @@ export class CodexAdapter extends LlmAdapter {
|
|
|
354
359
|
// The fetcher runs only on a cache miss, and resolves the session
|
|
355
360
|
// through the refresh-aware path so an expired access token renews here
|
|
356
361
|
// instead of failing discovery into the static fallback.
|
|
357
|
-
const discovered = await this.catalog.get(
|
|
362
|
+
const discovered = await this.catalog.get(() => this.fetchCatalog());
|
|
358
363
|
return discovered.map(model => ({
|
|
359
364
|
provider,
|
|
360
365
|
id: model.id,
|
|
@@ -375,14 +380,25 @@ export class CodexAdapter extends LlmAdapter {
|
|
|
375
380
|
return this.staticModels(provider);
|
|
376
381
|
}
|
|
377
382
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
383
|
+
/**
|
|
384
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
385
|
+
* stale-while-revalidate path so capability metadata stays stable across a
|
|
386
|
+
* long conversation: a discovered-only effort (one missing from the static
|
|
387
|
+
* CODEX_EFFORTS list) selected by the user must not vanish — and fail the
|
|
388
|
+
* call — just because the TTL lapsed mid-turn.
|
|
389
|
+
*/
|
|
390
|
+
async discovered(model) {
|
|
391
|
+
if (!this.options.discovery)
|
|
392
|
+
return undefined;
|
|
393
|
+
const models = await this.catalog.resolve(() => this.fetchCatalog());
|
|
394
|
+
return models?.find(entry => entry.id === model);
|
|
395
|
+
}
|
|
396
|
+
async resolveModel(provider, model) {
|
|
397
|
+
// Discovered metadata (when discovery is on) wins over the static entry;
|
|
398
|
+
// the static entry wins over the built-in defaults.
|
|
399
|
+
const discovered = await this.discovered(model);
|
|
384
400
|
const configured = this.options.models.find(entry => entry.id === model);
|
|
385
|
-
return
|
|
401
|
+
return {
|
|
386
402
|
provider,
|
|
387
403
|
id: model,
|
|
388
404
|
name: discovered?.name ?? configured?.name ?? model,
|
|
@@ -391,7 +407,7 @@ export class CodexAdapter extends LlmAdapter {
|
|
|
391
407
|
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
|
|
392
408
|
defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
|
|
393
409
|
reasoning: discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT },
|
|
394
|
-
}
|
|
410
|
+
};
|
|
395
411
|
}
|
|
396
412
|
async *stream(options) {
|
|
397
413
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
@@ -178,27 +178,69 @@ export interface DiscoveredModel {
|
|
|
178
178
|
}
|
|
179
179
|
/** How long a discovered catalog is trusted before re-fetching. */
|
|
180
180
|
export declare const DISCOVERY_TTL_MS: number;
|
|
181
|
+
/** A durable snapshot of one provider's discovered catalog. */
|
|
182
|
+
export interface CatalogSnapshot {
|
|
183
|
+
/** Epoch milliseconds of the successful fetch that produced it. */
|
|
184
|
+
at: number;
|
|
185
|
+
models: DiscoveredModel[];
|
|
186
|
+
}
|
|
187
|
+
/** The durable half of a {@link ModelCatalogCache} (the models.json store). */
|
|
188
|
+
export interface CatalogPersistence {
|
|
189
|
+
/** The last persisted snapshot, or undefined when absent or unusable. */
|
|
190
|
+
load(): Promise<CatalogSnapshot | undefined>;
|
|
191
|
+
/** Persist a fresh snapshot (write-through after every successful fetch). */
|
|
192
|
+
save(snapshot: CatalogSnapshot): Promise<void>;
|
|
193
|
+
/** Drop the persisted snapshot (a 401 proved the credential changed). */
|
|
194
|
+
clear(): Promise<void>;
|
|
195
|
+
}
|
|
181
196
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
197
|
+
* Cache for one provider's discovered model catalog. The TTL only decides
|
|
198
|
+
* when to REFRESH; it never makes the cache forget: capability metadata
|
|
199
|
+
* (reasoning efforts) must stay stable for a session that selected an effort,
|
|
200
|
+
* or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
|
|
201
|
+
* cache goes stale. `listModels` awaits freshness via {@link get};
|
|
202
|
+
* `resolveModel` uses {@link resolve}, which serves the last-known catalog
|
|
203
|
+
* while a stale entry refreshes in the background, and only awaits the fetch
|
|
204
|
+
* when nothing is known yet. An optional {@link CatalogPersistence} seeds the
|
|
205
|
+
* last-known state across restarts and receives every successful fetch. A 401
|
|
206
|
+
* during a fetch must call {@link invalidate}.
|
|
186
207
|
*/
|
|
187
208
|
export declare class ModelCatalogCache {
|
|
209
|
+
private readonly persistence?;
|
|
188
210
|
private readonly ttlMs;
|
|
189
211
|
private entry;
|
|
190
|
-
|
|
212
|
+
private inflight;
|
|
213
|
+
/** Settles once the persisted snapshot (when any) has been considered. */
|
|
214
|
+
private seeded;
|
|
215
|
+
/** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
|
|
216
|
+
private seedDisabled;
|
|
217
|
+
constructor(persistence?: CatalogPersistence | undefined, ttlMs?: number);
|
|
191
218
|
/**
|
|
192
219
|
* The cached catalog when fresh, without fetching.
|
|
193
220
|
* @returns the cached models, or `undefined` when absent or stale.
|
|
194
221
|
*/
|
|
195
222
|
cached(): readonly DiscoveredModel[] | undefined;
|
|
223
|
+
/** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
|
|
224
|
+
private ensureSeeded;
|
|
225
|
+
/** Run (or join) the single in-flight fetch, updating memory and disk on success. */
|
|
226
|
+
private refresh;
|
|
196
227
|
/**
|
|
197
228
|
* Return the cached catalog when fresh, otherwise fetch and cache it.
|
|
198
229
|
* @param fetcher - performs the provider's model-list request.
|
|
199
230
|
* @returns the discovered models.
|
|
231
|
+
* @throws the fetcher's failure (the `listModels` caller warns and falls back).
|
|
200
232
|
*/
|
|
201
233
|
get(fetcher: () => Promise<DiscoveredModel[]>): Promise<readonly DiscoveredModel[]>;
|
|
234
|
+
/**
|
|
235
|
+
* The models for capability resolution. A fresh cache answers directly; a
|
|
236
|
+
* stale one answers immediately from the last-known catalog while a
|
|
237
|
+
* background refresh runs (a mid-conversation `resolveModel` must neither
|
|
238
|
+
* block on nor fail with the network); a cold cache awaits one fetch.
|
|
239
|
+
* @param fetcher - performs the provider's model-list request.
|
|
240
|
+
* @returns the models, or `undefined` when nothing is known (the caller
|
|
241
|
+
* falls back to its static metadata). Never throws.
|
|
242
|
+
*/
|
|
243
|
+
resolve(fetcher: () => Promise<DiscoveredModel[]>): Promise<readonly DiscoveredModel[] | undefined>;
|
|
202
244
|
/** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
|
|
203
245
|
invalidate(): void;
|
|
204
246
|
}
|
package/lib/providers/common.js
CHANGED
|
@@ -262,15 +262,28 @@ export class TokenManager {
|
|
|
262
262
|
/** How long a discovered catalog is trusted before re-fetching. */
|
|
263
263
|
export const DISCOVERY_TTL_MS = 5 * 60_000;
|
|
264
264
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
265
|
+
* Cache for one provider's discovered model catalog. The TTL only decides
|
|
266
|
+
* when to REFRESH; it never makes the cache forget: capability metadata
|
|
267
|
+
* (reasoning efforts) must stay stable for a session that selected an effort,
|
|
268
|
+
* or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
|
|
269
|
+
* cache goes stale. `listModels` awaits freshness via {@link get};
|
|
270
|
+
* `resolveModel` uses {@link resolve}, which serves the last-known catalog
|
|
271
|
+
* while a stale entry refreshes in the background, and only awaits the fetch
|
|
272
|
+
* when nothing is known yet. An optional {@link CatalogPersistence} seeds the
|
|
273
|
+
* last-known state across restarts and receives every successful fetch. A 401
|
|
274
|
+
* during a fetch must call {@link invalidate}.
|
|
269
275
|
*/
|
|
270
276
|
export class ModelCatalogCache {
|
|
277
|
+
persistence;
|
|
271
278
|
ttlMs;
|
|
272
279
|
entry;
|
|
273
|
-
|
|
280
|
+
inflight;
|
|
281
|
+
/** Settles once the persisted snapshot (when any) has been considered. */
|
|
282
|
+
seeded;
|
|
283
|
+
/** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
|
|
284
|
+
seedDisabled = false;
|
|
285
|
+
constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
|
|
286
|
+
this.persistence = persistence;
|
|
274
287
|
this.ttlMs = ttlMs;
|
|
275
288
|
}
|
|
276
289
|
/**
|
|
@@ -282,21 +295,71 @@ export class ModelCatalogCache {
|
|
|
282
295
|
return undefined;
|
|
283
296
|
return this.entry.models;
|
|
284
297
|
}
|
|
298
|
+
/** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
|
|
299
|
+
ensureSeeded() {
|
|
300
|
+
if (this.persistence === undefined)
|
|
301
|
+
return Promise.resolve();
|
|
302
|
+
this.seeded ??= this.persistence.load().then((snapshot) => {
|
|
303
|
+
if (snapshot !== undefined && this.entry === undefined && !this.seedDisabled) {
|
|
304
|
+
this.entry = snapshot;
|
|
305
|
+
}
|
|
306
|
+
}, () => undefined);
|
|
307
|
+
return this.seeded;
|
|
308
|
+
}
|
|
309
|
+
/** Run (or join) the single in-flight fetch, updating memory and disk on success. */
|
|
310
|
+
refresh(fetcher) {
|
|
311
|
+
this.inflight ??= fetcher()
|
|
312
|
+
.then((models) => {
|
|
313
|
+
const snapshot = { at: Date.now(), models };
|
|
314
|
+
this.entry = snapshot;
|
|
315
|
+
// Write-through is fire-and-forget: a failed save only costs durability.
|
|
316
|
+
void this.persistence?.save(snapshot).catch(() => undefined);
|
|
317
|
+
return models;
|
|
318
|
+
})
|
|
319
|
+
.finally(() => { this.inflight = undefined; });
|
|
320
|
+
return this.inflight;
|
|
321
|
+
}
|
|
285
322
|
/**
|
|
286
323
|
* Return the cached catalog when fresh, otherwise fetch and cache it.
|
|
287
324
|
* @param fetcher - performs the provider's model-list request.
|
|
288
325
|
* @returns the discovered models.
|
|
326
|
+
* @throws the fetcher's failure (the `listModels` caller warns and falls back).
|
|
289
327
|
*/
|
|
290
328
|
async get(fetcher) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
329
|
+
await this.ensureSeeded();
|
|
330
|
+
return this.cached() ?? this.refresh(fetcher);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The models for capability resolution. A fresh cache answers directly; a
|
|
334
|
+
* stale one answers immediately from the last-known catalog while a
|
|
335
|
+
* background refresh runs (a mid-conversation `resolveModel` must neither
|
|
336
|
+
* block on nor fail with the network); a cold cache awaits one fetch.
|
|
337
|
+
* @param fetcher - performs the provider's model-list request.
|
|
338
|
+
* @returns the models, or `undefined` when nothing is known (the caller
|
|
339
|
+
* falls back to its static metadata). Never throws.
|
|
340
|
+
*/
|
|
341
|
+
async resolve(fetcher) {
|
|
342
|
+
await this.ensureSeeded();
|
|
343
|
+
const fresh = this.cached();
|
|
344
|
+
if (fresh !== undefined)
|
|
345
|
+
return fresh;
|
|
346
|
+
const known = this.entry?.models;
|
|
347
|
+
if (known !== undefined) {
|
|
348
|
+
// Stale-while-revalidate: the refresh outcome serves the NEXT resolve.
|
|
349
|
+
this.refresh(fetcher).catch(() => undefined);
|
|
350
|
+
return known;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
return await this.refresh(fetcher);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
297
358
|
}
|
|
298
359
|
/** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
|
|
299
360
|
invalidate() {
|
|
300
361
|
this.entry = undefined;
|
|
362
|
+
this.seedDisabled = true;
|
|
363
|
+
void this.persistence?.clear().catch(() => undefined);
|
|
301
364
|
}
|
|
302
365
|
}
|
package/lib/providers/grok.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { FlowSpec } from '../auth/oauth-flow.js';
|
|
|
9
9
|
import type { GrokSession } from '../auth/store.js';
|
|
10
10
|
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
11
11
|
import { TokenManager } from './common.js';
|
|
12
|
-
import type { DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
|
|
12
|
+
import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
|
|
13
13
|
export declare const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
14
14
|
export declare const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
|
|
15
15
|
export declare const GROK_API_URL = "https://api.x.ai/v1/responses";
|
|
@@ -121,15 +121,28 @@ export interface GrokAdapterOptions {
|
|
|
121
121
|
fetchFn?: FetchFn;
|
|
122
122
|
/** Resolve the attachment service per request; absent means image requests fail loudly. */
|
|
123
123
|
resolveAttachments?: () => AttachmentStore | undefined;
|
|
124
|
+
/** Durable catalog store seeding capability metadata across restarts. */
|
|
125
|
+
catalogStore?: CatalogPersistence;
|
|
124
126
|
}
|
|
125
127
|
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
126
128
|
export declare class GrokAdapter extends LlmAdapter {
|
|
127
129
|
private readonly options;
|
|
128
130
|
private readonly catalog;
|
|
129
131
|
constructor(options: GrokAdapterOptions);
|
|
132
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
133
|
+
private fetchCatalog;
|
|
130
134
|
providerInfo(provider: string): LlmProviderInfo;
|
|
131
135
|
private staticModels;
|
|
132
136
|
listModels(provider: string): Promise<readonly LlmModelInfo[]>;
|
|
137
|
+
/**
|
|
138
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
139
|
+
* stale-while-revalidate path: capability metadata must stay stable across
|
|
140
|
+
* a long conversation — a session that selected a reasoning effort calls
|
|
141
|
+
* this on EVERY step, and forgetting the efforts just because the TTL
|
|
142
|
+
* lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
|
|
143
|
+
* before provider I/O.
|
|
144
|
+
*/
|
|
145
|
+
private discovered;
|
|
133
146
|
resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
134
147
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
135
148
|
private request;
|
package/lib/providers/grok.js
CHANGED
|
@@ -405,10 +405,15 @@ export async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
|
|
|
405
405
|
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
406
406
|
export class GrokAdapter extends LlmAdapter {
|
|
407
407
|
options;
|
|
408
|
-
catalog
|
|
408
|
+
catalog;
|
|
409
409
|
constructor(options) {
|
|
410
410
|
super();
|
|
411
411
|
this.options = options;
|
|
412
|
+
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
413
|
+
}
|
|
414
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
415
|
+
async fetchCatalog() {
|
|
416
|
+
return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn);
|
|
412
417
|
}
|
|
413
418
|
providerInfo(provider) {
|
|
414
419
|
return { id: provider, name: 'Grok (Subscription)' };
|
|
@@ -432,7 +437,7 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
432
437
|
// The fetcher runs only on a cache miss, and resolves the session
|
|
433
438
|
// through the refresh-aware path so an expired access token renews here
|
|
434
439
|
// instead of failing discovery into the static fallback.
|
|
435
|
-
const discovered = await this.catalog.get(
|
|
440
|
+
const discovered = await this.catalog.get(() => this.fetchCatalog());
|
|
436
441
|
return discovered.map(model => ({
|
|
437
442
|
provider,
|
|
438
443
|
id: model.id,
|
|
@@ -453,12 +458,24 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
453
458
|
return this.staticModels(provider);
|
|
454
459
|
}
|
|
455
460
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
461
|
+
/**
|
|
462
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
463
|
+
* stale-while-revalidate path: capability metadata must stay stable across
|
|
464
|
+
* a long conversation — a session that selected a reasoning effort calls
|
|
465
|
+
* this on EVERY step, and forgetting the efforts just because the TTL
|
|
466
|
+
* lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
|
|
467
|
+
* before provider I/O.
|
|
468
|
+
*/
|
|
469
|
+
async discovered(model) {
|
|
470
|
+
if (!this.options.discovery)
|
|
471
|
+
return undefined;
|
|
472
|
+
const models = await this.catalog.resolve(() => this.fetchCatalog());
|
|
473
|
+
return models?.find(entry => entry.id === model);
|
|
474
|
+
}
|
|
475
|
+
async resolveModel(provider, model) {
|
|
476
|
+
const discovered = await this.discovered(model);
|
|
460
477
|
const configured = this.options.models.find(entry => entry.id === model);
|
|
461
|
-
return
|
|
478
|
+
return {
|
|
462
479
|
provider,
|
|
463
480
|
id: model,
|
|
464
481
|
name: discovered?.name ?? configured?.name ?? model,
|
|
@@ -470,7 +487,7 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
470
487
|
// cover expose none, so the harness rejects explicit efforts before
|
|
471
488
|
// provider I/O instead of the API 400ing.
|
|
472
489
|
...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
|
|
473
|
-
}
|
|
490
|
+
};
|
|
474
491
|
}
|
|
475
492
|
async *stream(options) {
|
|
476
493
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subscriptions",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Use ChatGPT (Codex), Claude, and Grok (X Premium) subscriptions as DeepSeek Harness LLM providers, with OAuth login from the web Settings page",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|