@fonlan/dsh-model-router 0.3.6 → 0.3.8

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 CHANGED
@@ -311,10 +311,36 @@ function setOrder(config, modelId, order, catalogProviderIds) {
311
311
  changed: true
312
312
  };
313
313
  }
314
+ /**
315
+ * Dereference one config field as the loader may hand it over.
316
+ *
317
+ * dsh >= 0.1.7 owns plugin configuration as entry config, and every field the
318
+ * `Config` schema marks `.volatile()` arrives as a stable REFERENCE
319
+ * (`{ get() }`, schemastery's `Volatile<T>`) instead of a plain value — a
320
+ * reference serializes to `{}`. Reading such a field raw silently yields
321
+ * `models: {}`, `modelSort: 'custom'`, an empty `modelOrder` and no
322
+ * `recentlyUsed`: the stored document is dropped, and the next persist
323
+ * overwrites it with catalog defaults. Plain values pass through unchanged, so
324
+ * this is safe for the write paths and for callers that already hold a
325
+ * resolved document.
326
+ */
327
+ function derefField(value) {
328
+ if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
329
+ return value;
330
+ }
331
+ /** Dereference every own field of a config object (per-field volatile refs). */
332
+ function derefConfig(value) {
333
+ const root = derefField(value);
334
+ if (root === null || typeof root !== "object" || Array.isArray(root)) return root;
335
+ const out = {};
336
+ for (const [key, field] of Object.entries(root)) out[key] = derefField(field);
337
+ return out;
338
+ }
314
339
  /** Shape-validate an unknown value as a RouterConfigShape (lenient). */
315
340
  function normalizeConfig(value) {
316
341
  const models = {};
317
- if (typeof value !== "object" || value === null) return {
342
+ const source = derefConfig(value);
343
+ if (typeof source !== "object" || source === null) return {
318
344
  models,
319
345
  showQuickSwitch: true,
320
346
  ignoreModelIdPrefix: true,
@@ -322,7 +348,7 @@ function normalizeConfig(value) {
322
348
  modelOrder: [],
323
349
  recentlyUsed: {}
324
350
  };
325
- const root = value;
351
+ const root = source;
326
352
  const showQuickSwitch = root.showQuickSwitch !== false;
327
353
  const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false;
328
354
  const modelSort = root.modelSort === "name" || root.modelSort === "recent" ? root.modelSort : "custom";
@@ -362,10 +388,18 @@ function normalizeConfig(value) {
362
388
  }
363
389
  //#endregion
364
390
  //#region src/server/catalog.ts
365
- /** Read the resolved value of another plugin's settings section, if registered. */
391
+ /**
392
+ * Read the resolved value of another plugin's settings section, if the
393
+ * settings plane describes it. The descriptor's `ns` is the profile entry id,
394
+ * so the value is that entry's resolved Config (defaults → composition base →
395
+ * user layer). `describe()` lists only entries that declare volatile
396
+ * (form-writable) fields, and redaction is off here because the caller reads a
397
+ * non-secret field (`apiKeyEnv`). An undescribed section, or an unavailable
398
+ * settings service, yields undefined.
399
+ */
366
400
  function settingsSection(ctx, ns) {
367
401
  try {
368
- return ctx.settings.get(ns);
402
+ return ctx.settings.describe({ redactSecrets: false }).find((candidate) => candidate.ns === ns)?.value;
369
403
  } catch {
370
404
  return;
371
405
  }
@@ -584,30 +618,39 @@ var ModelRouterAdapter = class extends LlmAdapter {
584
618
  //#endregion
585
619
  //#region src/server/service.ts
586
620
  const NS = ROUTER_SETTINGS_NS;
587
- /** Settings document schema: one entry per model id. */
621
+ /**
622
+ * Settings document schema: one entry per model id.
623
+ *
624
+ * Every writable field stays `.volatile()`: the settings plane refuses a
625
+ * namespace whose form has no volatile field, and `settings.replace` refuses
626
+ * non-volatile paths. The exported schema is nevertheless ASSERTED to the
627
+ * plain `z<RouterConfigShape>` (the declared type it had before the
628
+ * schemastery bump) for two reasons:
629
+ * - since schemastery 3.18.3 a `.volatile()` field's output types as
630
+ * `Volatile<T>`, so `: z<RouterConfigShape>` no longer *compiles*, while the
631
+ * inferred schema type cannot be named in the emitted declarations either
632
+ * (TS2742: it would need a pnpm-nested cosmokit type, and `pnpm build` runs
633
+ * tsc in declaration mode);
634
+ * - the plain shape is what consumers actually observe: the loader hands the
635
+ * fields over as live `{ get() }` cells and `normalizeConfig` unwinds them.
636
+ * The assertion is type-level only — the runtime schema keeps its volatile
637
+ * markers, which is what the settings plane reads.
638
+ */
588
639
  const RouterConfigSchema = z.object({
589
640
  models: z.dict(z.object({
590
641
  order: z.array(z.string()),
591
642
  active: z.string()
592
- })),
593
- showQuickSwitch: z.boolean(),
594
- ignoreModelIdPrefix: z.boolean(),
643
+ })).volatile(),
644
+ showQuickSwitch: z.boolean().volatile(),
645
+ ignoreModelIdPrefix: z.boolean().volatile(),
595
646
  modelSort: z.union([
596
647
  z.const("custom"),
597
648
  z.const("name"),
598
649
  z.const("recent")
599
- ]),
600
- modelOrder: z.array(z.string()),
601
- recentlyUsed: z.dict(z.number())
650
+ ]).volatile(),
651
+ modelOrder: z.array(z.string()).volatile(),
652
+ recentlyUsed: z.dict(z.number()).volatile()
602
653
  });
603
- const EMPTY_CONFIG = {
604
- models: {},
605
- showQuickSwitch: true,
606
- ignoreModelIdPrefix: true,
607
- modelSort: "custom",
608
- modelOrder: [],
609
- recentlyUsed: {}
610
- };
611
654
  var ModelRouterService = class {
612
655
  ctx;
613
656
  cached = {
@@ -619,22 +662,30 @@ var ModelRouterService = class {
619
662
  };
620
663
  inflight = null;
621
664
  cachedAt = 0;
622
- scope;
665
+ /** Optional host settings service; absent in profiles without one (no persistence). */
666
+ settings;
667
+ /** Live config document (seeded from the entry config, updated on write). */
668
+ configDoc;
623
669
  registration;
624
670
  /** How long a built catalog is considered fresh before being rebuilt. */
625
671
  catalogTtlMs = 3e4;
626
- /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
627
- usageFlushMs = 1500;
672
+ /**
673
+ * Debounced recent-use persistence (see noteModelUsed / flushUsage). Kept
674
+ * long on purpose: every persist writes the entry config, which restarts
675
+ * this plugin's entry, so usage bookkeeping must not stream live traffic.
676
+ */
677
+ usageFlushMs = 6e4;
628
678
  usageDebounce = null;
629
679
  pendingUsage = {};
630
680
  usageFlushRunning = false;
631
- constructor(ctx) {
681
+ constructor(ctx, config) {
632
682
  this.ctx = ctx;
683
+ this.configDoc = normalizeConfig(config);
633
684
  }
634
- /** Register settings + adapter and wire topology listeners. */
685
+ /** Register the settings seam + adapter and wire topology listeners. */
635
686
  start() {
636
687
  this.ctx.inject(["settings"], (sctx) => {
637
- this.scope = sctx.settings.register(NS, RouterConfigSchema);
688
+ this.settings = sctx.settings;
638
689
  });
639
690
  const adapter = new ModelRouterAdapter(this);
640
691
  this.registration = this.ctx.llm.registerAdapter([ROUTER_PROVIDER_ID], adapter);
@@ -648,7 +699,7 @@ var ModelRouterService = class {
648
699
  clearTimeout(this.usageDebounce);
649
700
  this.usageDebounce = null;
650
701
  }
651
- if (Object.keys(this.pendingUsage).length > 0) this.flushUsage();
702
+ this.pendingUsage = {};
652
703
  this.registration?.();
653
704
  this.registration = void 0;
654
705
  }
@@ -659,12 +710,7 @@ var ModelRouterService = class {
659
710
  };
660
711
  }
661
712
  config() {
662
- if (this.scope === void 0) return EMPTY_CONFIG;
663
- try {
664
- return normalizeConfig(this.scope.get());
665
- } catch {
666
- return EMPTY_CONFIG;
667
- }
713
+ return this.configDoc;
668
714
  }
669
715
  providerNames() {
670
716
  return this.cached.providerNames;
@@ -679,7 +725,7 @@ var ModelRouterService = class {
679
725
  * write.
680
726
  */
681
727
  noteModelUsed(modelId) {
682
- if (this.scope === void 0) return;
728
+ if (this.settings === void 0) return;
683
729
  this.pendingUsage[modelId] = Date.now();
684
730
  if (this.usageDebounce !== null) return;
685
731
  this.usageDebounce = setTimeout(() => {
@@ -694,9 +740,9 @@ var ModelRouterService = class {
694
740
  const pending = this.pendingUsage;
695
741
  this.pendingUsage = {};
696
742
  try {
697
- if (this.scope === void 0 || Object.keys(pending).length === 0) return;
743
+ if (this.settings === void 0 || Object.keys(pending).length === 0) return;
698
744
  const current = this.config();
699
- await this.scope.replace({
745
+ await this.writeConfig({
700
746
  ...current,
701
747
  recentlyUsed: {
702
748
  ...current.recentlyUsed,
@@ -743,12 +789,12 @@ var ModelRouterService = class {
743
789
  /** Refresh (forced), then persist any config drift (migration, new/vanished models). */
744
790
  async refreshAndReconcile() {
745
791
  const catalog = await this.refreshCatalog(true);
746
- if (this.scope === void 0) return;
792
+ if (this.settings === void 0) return;
747
793
  const current = this.config();
748
794
  const { models, modelOrder, changed } = reconcileConfig(current, catalog.models);
749
795
  if (!changed) return;
750
796
  try {
751
- await this.scope.replace({
797
+ await this.writeConfig({
752
798
  ...current,
753
799
  models,
754
800
  ...modelOrder === void 0 ? {} : { modelOrder }
@@ -770,6 +816,7 @@ var ModelRouterService = class {
770
816
  const names = catalog.providerNames;
771
817
  const creds = catalog.credentialConfigured;
772
818
  return {
819
+ writable: this.settings !== void 0,
773
820
  showQuickSwitch: config.showQuickSwitch,
774
821
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
775
822
  modelSort: config.modelSort,
@@ -818,10 +865,9 @@ var ModelRouterService = class {
818
865
  }
819
866
  /** Toggle the composer quick route-switcher button (global, persisted). */
820
867
  async setShowQuickSwitch(value) {
821
- if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
822
868
  const current = this.config();
823
869
  if (current.showQuickSwitch === value) return;
824
- await this.scope.replace({
870
+ await this.writeConfig({
825
871
  ...current,
826
872
  showQuickSwitch: value
827
873
  });
@@ -833,10 +879,9 @@ var ModelRouterService = class {
833
879
  * turning on; split models re-initialize when turning off).
834
880
  */
835
881
  async setIgnoreModelIdPrefix(value) {
836
- if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
837
882
  const current = this.config();
838
883
  if (current.ignoreModelIdPrefix === value) return;
839
- await this.scope.replace({
884
+ await this.writeConfig({
840
885
  ...current,
841
886
  ignoreModelIdPrefix: value
842
887
  });
@@ -844,10 +889,9 @@ var ModelRouterService = class {
844
889
  }
845
890
  /** Switch the model-list display order mode (global, persisted). */
846
891
  async setModelSort(mode) {
847
- if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
848
892
  const current = this.config();
849
893
  if (current.modelSort === mode) return;
850
- await this.scope.replace({
894
+ await this.writeConfig({
851
895
  ...current,
852
896
  modelSort: mode
853
897
  });
@@ -858,22 +902,26 @@ var ModelRouterService = class {
858
902
  * always covers the live catalog.
859
903
  */
860
904
  async setModelOrder(order) {
861
- if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
862
905
  await this.refreshCatalog();
863
906
  const ids = new Set(this.cached.models.map((model) => model.id));
864
907
  const clean = order.filter((id) => ids.has(id));
865
908
  for (const model of this.cached.models) if (!clean.includes(model.id)) clean.push(model.id);
866
909
  const current = this.config();
867
910
  if (clean.length === current.modelOrder.length && clean.every((id, at) => id === current.modelOrder[at])) return;
868
- await this.scope.replace({
911
+ await this.writeConfig({
869
912
  ...current,
870
913
  modelOrder: clean
871
914
  });
872
915
  }
916
+ /** Write the document through the host settings service (entry config). */
917
+ async writeConfig(next) {
918
+ if (this.settings === void 0) throw new Error("model-router: settings service is not available in this profile");
919
+ await this.settings.replace(NS, next, void 0);
920
+ this.configDoc = normalizeConfig(next);
921
+ }
873
922
  async persist(models) {
874
- if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
875
923
  const current = this.config();
876
- await this.scope.replace({
924
+ await this.writeConfig({
877
925
  ...current,
878
926
  models
879
927
  });
@@ -1023,9 +1071,9 @@ function requireString(value, name) {
1023
1071
  //#region src/index.ts
1024
1072
  const name = "@fonlan/dsh-model-router";
1025
1073
  const inject = ["llm"];
1026
- const Config = z.object({});
1027
- function apply(ctx) {
1028
- const service = new ModelRouterService(ctx);
1074
+ const Config = RouterConfigSchema;
1075
+ function apply(ctx, config) {
1076
+ const service = new ModelRouterService(ctx, config);
1029
1077
  ctx.effect(() => {
1030
1078
  service.start();
1031
1079
  return () => service.stop();
@@ -3,13 +3,14 @@
3
3
  *
4
4
  * Registers one `model-router` LLM adapter that aggregates every configured
5
5
  * provider and model (strict model-id merge), routes each request to the
6
- * model's currently active provider by in-process delegation, persists the
7
- * per-model provider order/active config in the `model-router` settings
8
- * namespace, and serves the fenced JSON API the web settings page calls.
6
+ * model's currently active provider by in-process delegation, keeps the
7
+ * per-model provider order/active config in the entry config of this bundle
8
+ * (dsh >= 0.1.7: the `Config` schema below is the settings document), and
9
+ * serves the fenced JSON API the web settings page calls.
9
10
  */
10
11
  import type { Context } from '@deepseek-ai/cordis';
11
- import z from '@deepseek-ai/schemastery';
12
+ import type { RouterConfigShape } from './shared/config.js';
12
13
  export declare const name = "@fonlan/dsh-model-router";
13
14
  export declare const inject: string[];
14
- export declare const Config: z<Schemastery.ObjectS<{}>, Schemastery.ObjectT<{}>>;
15
- export declare function apply(ctx: Context): void;
15
+ export declare const Config: import("@deepseek-ai/schemastery").default<RouterConfigShape>;
16
+ export declare function apply(ctx: Context, config: RouterConfigShape): void;
@@ -9,10 +9,41 @@ import { type ModelSortMode, type RouterConfigShape } from '../shared/config.js'
9
9
  import { type RouterCatalog } from './catalog.js';
10
10
  import { type RouterFacts } from './adapter.js';
11
11
  export declare const NS = "model-router";
12
- /** Settings document schema: one entry per model id. */
12
+ /**
13
+ * The slice of the host settings service this plugin writes through. dsh >=
14
+ * 0.1.7 owns plugin configuration as entry config (the plugin's `Config`
15
+ * schema), so the router reads the document it was applied with and persists
16
+ * updates via `settings.replace`, which writes the profile patch.
17
+ */
18
+ export interface SettingsReplaceFace {
19
+ replace(ns: string, section: object, expectedRevision?: number): Promise<void>;
20
+ }
21
+ /**
22
+ * Settings document schema: one entry per model id.
23
+ *
24
+ * Every writable field stays `.volatile()`: the settings plane refuses a
25
+ * namespace whose form has no volatile field, and `settings.replace` refuses
26
+ * non-volatile paths. The exported schema is nevertheless ASSERTED to the
27
+ * plain `z<RouterConfigShape>` (the declared type it had before the
28
+ * schemastery bump) for two reasons:
29
+ * - since schemastery 3.18.3 a `.volatile()` field's output types as
30
+ * `Volatile<T>`, so `: z<RouterConfigShape>` no longer *compiles*, while the
31
+ * inferred schema type cannot be named in the emitted declarations either
32
+ * (TS2742: it would need a pnpm-nested cosmokit type, and `pnpm build` runs
33
+ * tsc in declaration mode);
34
+ * - the plain shape is what consumers actually observe: the loader hands the
35
+ * fields over as live `{ get() }` cells and `normalizeConfig` unwinds them.
36
+ * The assertion is type-level only — the runtime schema keeps its volatile
37
+ * markers, which is what the settings plane reads.
38
+ */
13
39
  export declare const RouterConfigSchema: z<RouterConfigShape>;
14
40
  /** The settings-page state view served by the API. */
15
41
  export interface ModelRouterState {
42
+ /**
43
+ * Whether this deployment can persist configuration. False (a profile
44
+ * without the host settings service) makes the page render read-only.
45
+ */
46
+ writable: boolean;
16
47
  /** Whether provider switching is shown inside the composer model picker menu. */
17
48
  showQuickSwitch: boolean;
18
49
  /** Whether model ids are matched with their leading vendor/ prefix ignored. */
@@ -46,17 +77,24 @@ export declare class ModelRouterService implements RouterFacts {
46
77
  private cached;
47
78
  private inflight;
48
79
  private cachedAt;
49
- private scope;
80
+ /** Optional host settings service; absent in profiles without one (no persistence). */
81
+ private settings;
82
+ /** Live config document (seeded from the entry config, updated on write). */
83
+ private configDoc;
50
84
  private registration;
51
85
  /** How long a built catalog is considered fresh before being rebuilt. */
52
86
  private readonly catalogTtlMs;
53
- /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
87
+ /**
88
+ * Debounced recent-use persistence (see noteModelUsed / flushUsage). Kept
89
+ * long on purpose: every persist writes the entry config, which restarts
90
+ * this plugin's entry, so usage bookkeeping must not stream live traffic.
91
+ */
54
92
  private readonly usageFlushMs;
55
93
  private usageDebounce;
56
94
  private pendingUsage;
57
95
  private usageFlushRunning;
58
- constructor(ctx: Context);
59
- /** Register settings + adapter and wire topology listeners. */
96
+ constructor(ctx: Context, config: RouterConfigShape);
97
+ /** Register the settings seam + adapter and wire topology listeners. */
60
98
  start(): void;
61
99
  stop(): void;
62
100
  catalog(): {
@@ -115,5 +153,7 @@ export declare class ModelRouterService implements RouterFacts {
115
153
  * always covers the live catalog.
116
154
  */
117
155
  setModelOrder(order: string[]): Promise<void>;
156
+ /** Write the document through the host settings service (entry config). */
157
+ private writeConfig;
118
158
  private persist;
119
159
  }
@@ -198,5 +198,7 @@ export declare function setOrder(config: RouterConfigShape, modelId: string, ord
198
198
  models: Record<string, RouterModelConfig>;
199
199
  changed: boolean;
200
200
  };
201
+ /** Dereference every own field of a config object (per-field volatile refs). */
202
+ export declare function derefConfig(value: unknown): unknown;
201
203
  /** Shape-validate an unknown value as a RouterConfigShape (lenient). */
202
204
  export declare function normalizeConfig(value: unknown): RouterConfigShape;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-model-router",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "DSH plugin: a virtual model-router provider that aggregates every configured provider and model, routes each request to the model's currently active provider, and ships a settings page for per-model provider ordering and switching.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -63,36 +63,36 @@
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@deepseek-ai/cordis": "^4.0.1",
66
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
72
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
73
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
66
74
  "@deepseek-ai/schemastery": "^3.18.2",
67
- "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
68
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
69
- "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
70
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
71
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
72
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
73
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
74
- "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.2-alpha.2",
75
75
  "react": "^18.2.0",
76
76
  "react-dom": "^18.2.0"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@deepseek-ai/cordis": "^4.0.1",
80
- "@deepseek-ai/dsh-attachment": "0.1.2-alpha.2",
81
- "@deepseek-ai/dsh-brand": "0.1.2-alpha.2",
82
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
83
- "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
84
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
85
- "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.2-alpha.2",
86
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
87
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
88
- "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
89
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
90
- "@deepseek-ai/dsh-invariants": "0.1.2-alpha.2",
91
- "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
92
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
93
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
94
- "@deepseek-ai/dsh-timeout": "0.1.2-alpha.2",
95
- "@deepseek-ai/schemastery": "^3.18.2",
80
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
81
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.1",
82
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
83
+ "@deepseek-ai/dsh-client-store": "0.2.0-rc.1",
84
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
85
+ "@deepseek-ai/dsh-client-ui-model-selection": "0.2.0-rc.1",
86
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
87
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.1",
88
+ "@deepseek-ai/dsh-credentials": "0.2.0-rc.1",
89
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
90
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.1",
91
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
92
+ "@deepseek-ai/dsh-session": "0.2.0-rc.1",
93
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
94
+ "@deepseek-ai/dsh-timeout": "0.2.0-rc.1",
95
+ "@deepseek-ai/schemastery": "^3.18.3",
96
96
  "@types/node": "^22.0.0",
97
97
  "@types/react": "~18.3.1",
98
98
  "@types/react-dom": "~18.3.0",
package/src/client/api.ts CHANGED
@@ -16,6 +16,11 @@ export interface RouterModelView {
16
16
  }
17
17
 
18
18
  export interface ModelRouterState {
19
+ /**
20
+ * Whether this deployment can persist router configuration (the host half
21
+ * found the settings service). False renders the page read-only.
22
+ */
23
+ writable: boolean
19
24
  /** Whether provider switching is shown inside the composer model picker menu. */
20
25
  showQuickSwitch: boolean
21
26
  /** Whether model ids are matched with their leading vendor/ prefix ignored. */
@@ -0,0 +1,97 @@
1
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
2
+
3
+ /** Props every shell icon accepts: square edge in px, color rides currentColor. */
4
+ export interface IconProps {
5
+ size?: number
6
+ className?: string
7
+ }
8
+
9
+ /** One shell icon component. */
10
+ export type IconComponent = (props: IconProps) => JSX.Element
11
+
12
+ /** The runtime export table, read by capability instead of by one hard-coded name. */
13
+ const table = primitives as unknown as Record<string, IconComponent | undefined>
14
+
15
+ /** First name the runtime actually provides (a missing export is `undefined`). */
16
+ function firstDefined(...names: string[]): IconComponent | undefined {
17
+ for (const name of names) {
18
+ let candidate: IconComponent | undefined
19
+ try {
20
+ candidate = table[name]
21
+ } catch {
22
+ // Some module namespaces reject unknown names instead of yielding
23
+ // `undefined`; a rename must never throw on its way to the fallback.
24
+ continue
25
+ }
26
+ if (typeof candidate === 'function') return candidate
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /** One-path stroked glyph fallback (16×16 viewBox — the 0.1.7 artwork). */
32
+ function glyph(path: string, defaultSize: number): IconComponent {
33
+ return function Glyph({ size = defaultSize, className }: IconProps): JSX.Element {
34
+ return (
35
+ <svg
36
+ width={size}
37
+ height={size}
38
+ className={className}
39
+ viewBox="0 0 16 16"
40
+ fill="none"
41
+ xmlns="http://www.w3.org/2000/svg"
42
+ aria-hidden="true"
43
+ strokeWidth={1}
44
+ >
45
+ <path d={path} stroke="currentColor" />
46
+ </svg>
47
+ )
48
+ }
49
+ }
50
+
51
+ /** Warning circle fallback (the 0.1.7 artwork: ring, stem, dot). */
52
+ function WarningFallback({ size = 16, className }: IconProps): JSX.Element {
53
+ return (
54
+ <svg
55
+ width={size}
56
+ height={size}
57
+ className={className}
58
+ viewBox="0 0 16 16"
59
+ fill="none"
60
+ xmlns="http://www.w3.org/2000/svg"
61
+ aria-hidden="true"
62
+ strokeWidth={1}
63
+ >
64
+ <path d="M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z" stroke="currentColor" />
65
+ <path d="M8 4.29199V9.79199" stroke="currentColor" />
66
+ <path d="M8 10.708V11.708" stroke="currentColor" />
67
+ </svg>
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Shell icons used by the model seat.
73
+ *
74
+ * DSH 0.1.7 renamed every size-suffixed icon export (`IconCheckOutline16`,
75
+ * `IconChevronDownOutline14`, …) into stroke-weight variants (`…OutlineRegular`
76
+ * 1px / `…OutlineMedium` 1.3px). A stale named import resolves to `undefined`
77
+ * and React then throws "Element type is invalid" while rendering the slot
78
+ * entry — the whole seat is dropped from the conversation input instead of
79
+ * degrading (this is exactly what the 0.1.7 upgrade did to this plugin).
80
+ * Resolve the names at runtime, newest first, so the next rename lands on
81
+ * another shipped variant or on the inlined artwork below.
82
+ */
83
+ export const CheckIcon: IconComponent =
84
+ firstDefined('IconCheckOutlineRegular', 'IconCheckOutlineMedium', 'IconCheckOutline16')
85
+ ?? glyph('M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4', 16)
86
+
87
+ export const ChevronDownIcon: IconComponent =
88
+ firstDefined('IconChevronDownOutlineRegular', 'IconChevronDownOutlineMedium', 'IconChevronDownOutline14')
89
+ ?? glyph('M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6', 14)
90
+
91
+ export const ChevronRightIcon: IconComponent =
92
+ firstDefined('IconChevronRightOutlineRegular', 'IconChevronRightOutlineMedium', 'IconChevronRightOutline14')
93
+ ?? glyph('M6 12L9.29289 8.70711C9.68342 8.31658 9.68342 7.68342 9.29289 7.29289L6 4', 14)
94
+
95
+ export const WarningIcon: IconComponent =
96
+ firstDefined('IconWarningOutlineRegular', 'IconWarningOutlineMedium', 'IconWarningOutline16')
97
+ ?? WarningFallback