dsh-openrouter-provider-advisor 0.1.1 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [0.1.2] - 2026-08-24
6
+
7
+ ### Added
8
+
9
+ - Switch history with the provider pair and before/after throughput of each switch, persisted across sessions and offering one-click revert.
10
+ - Recommendation freshness line showing how long ago the ranking data was fetched.
11
+
12
+ ### Changed
13
+
14
+ - The panel now remembers the last ranking strategy across close, reopen, and reload instead of resetting to Best overall.
15
+ - Applying a provider removes the plugin's own stale `@preset/` entries for the same OpenRouter model, so repeated switching no longer accumulates near-duplicate models. User-authored model entries are left untouched.
16
+ - Neutral panel colors follow the host `--dsw-alias-*` theme tokens, with the previous dark values as fallbacks.
17
+
18
+ ### Fixed
19
+
20
+ - The provider dialog closes on Escape, traps Tab focus inside itself, and returns focus to the trigger button on close.
21
+
22
+ ### Validation
23
+
24
+ - 79 automated tests passing.
25
+ - TypeScript typecheck and production build passing.
26
+ - `npm audit --omit=dev` reports zero vulnerabilities.
27
+
5
28
  ## [0.1.1] - 2026-08-24
6
29
 
7
30
  ### Added
@@ -28,5 +51,6 @@ All notable changes to this project are documented here.
28
51
 
29
52
  - Initial public release.
30
53
 
54
+ [0.1.2]: https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/compare/v0.1.1...v0.1.2
31
55
  [0.1.1]: https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/compare/v0.1.0...v0.1.1
32
56
  [0.1.0]: https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/releases/tag/v0.1.0
package/lib/client.js CHANGED
@@ -32,7 +32,40 @@ async function responseJson(response) {
32
32
  if (!response.ok) throw new Error(body.error ?? `HTTP ${response.status}`);
33
33
  return body;
34
34
  }
35
- function createProviderPanelStore(fetchImpl = fetch) {
35
+ const STRATEGY_KEY = "openrouter-providers:strategy";
36
+ const HISTORY_KEY = "openrouter-providers:history";
37
+ const HISTORY_LIMIT = 20;
38
+ const KNOWN_STRATEGIES = new Set([
39
+ "balanced",
40
+ "price",
41
+ "speed",
42
+ "context"
43
+ ]);
44
+ function defaultStorage() {
45
+ try {
46
+ return globalThis.localStorage;
47
+ } catch {
48
+ return;
49
+ }
50
+ }
51
+ function readStrategy(storage) {
52
+ try {
53
+ const value = storage?.getItem(STRATEGY_KEY);
54
+ return value !== null && value !== void 0 && KNOWN_STRATEGIES.has(value) ? value : "balanced";
55
+ } catch {
56
+ return "balanced";
57
+ }
58
+ }
59
+ function readHistory(storage) {
60
+ try {
61
+ const parsed = JSON.parse(storage?.getItem(HISTORY_KEY) ?? "[]");
62
+ if (!Array.isArray(parsed)) return [];
63
+ return parsed.filter((item) => Boolean(item && typeof item === "object" && typeof item.at === "number" && typeof item.model === "string" && typeof item.toTag === "string")).slice(0, HISTORY_LIMIT);
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+ function createProviderPanelStore(fetchImpl = fetch, storage = defaultStorage()) {
36
69
  let snapshot = {
37
70
  open: false,
38
71
  status: "idle",
@@ -40,13 +73,14 @@ function createProviderPanelStore(fetchImpl = fetch) {
40
73
  data: null,
41
74
  models: [],
42
75
  selected: null,
43
- strategy: "balanced",
76
+ strategy: readStrategy(storage),
44
77
  error: null,
45
78
  credential: null,
46
79
  applyingTag: null,
47
80
  successTag: null,
48
81
  updatedAt: null,
49
- previousTag: null
82
+ previousTag: null,
83
+ history: readHistory(storage)
50
84
  };
51
85
  const listeners = /* @__PURE__ */ new Set();
52
86
  let generation = 0;
@@ -124,7 +158,6 @@ function createProviderPanelStore(fetchImpl = fetch) {
124
158
  open: true,
125
159
  status: "loading",
126
160
  sessionId,
127
- strategy: "balanced",
128
161
  data: null,
129
162
  models: [],
130
163
  selected: null,
@@ -157,7 +190,7 @@ function createProviderPanelStore(fetchImpl = fetch) {
157
190
  });
158
191
  return;
159
192
  }
160
- await load(sessionId, false, selected, "balanced");
193
+ await load(sessionId, false, selected);
161
194
  } catch (error) {
162
195
  if (operation.id !== generation || operation.signal.aborted) return;
163
196
  publish({
@@ -175,7 +208,6 @@ function createProviderPanelStore(fetchImpl = fetch) {
175
208
  publish({
176
209
  open: false,
177
210
  status: snapshot.data ? "ready" : "idle",
178
- strategy: "balanced",
179
211
  error: null
180
212
  });
181
213
  },
@@ -193,6 +225,9 @@ function createProviderPanelStore(fetchImpl = fetch) {
193
225
  await load(sessionId, false, selected);
194
226
  },
195
227
  async setStrategy(sessionId, strategy) {
228
+ try {
229
+ storage?.setItem(STRATEGY_KEY, strategy);
230
+ } catch {}
196
231
  publish({ strategy });
197
232
  await load(sessionId, false, snapshot.selected, strategy);
198
233
  },
@@ -224,15 +259,36 @@ function createProviderPanelStore(fetchImpl = fetch) {
224
259
  ...row,
225
260
  current: row.tag === tag
226
261
  });
262
+ const data = snapshot.data ? {
263
+ ...snapshot.data,
264
+ currentTag: tag,
265
+ recommended: snapshot.data.recommended.map(markCurrent),
266
+ rest: snapshot.data.rest.map(markCurrent)
267
+ } : null;
268
+ let history = snapshot.history;
269
+ if (snapshot.data && previousTag !== tag) {
270
+ const rows = [...snapshot.data.recommended, ...snapshot.data.rest];
271
+ const fromRow = previousTag === null ? void 0 : rows.find((row) => row.tag === previousTag);
272
+ const toRow = rows.find((row) => row.tag === tag);
273
+ history = [{
274
+ at: Date.now(),
275
+ model: snapshot.data.openrouterModel,
276
+ fromTag: previousTag,
277
+ fromName: fromRow?.providerName ?? null,
278
+ fromTps: fromRow?.tps ?? null,
279
+ toTag: tag,
280
+ toName: toRow?.providerName ?? tag,
281
+ toTps: toRow?.tps ?? null
282
+ }, ...snapshot.history].slice(0, HISTORY_LIMIT);
283
+ try {
284
+ storage?.setItem(HISTORY_KEY, JSON.stringify(history));
285
+ } catch {}
286
+ }
227
287
  publish({
228
288
  open: true,
229
289
  status: "ready",
230
- data: snapshot.data ? {
231
- ...snapshot.data,
232
- currentTag: tag,
233
- recommended: snapshot.data.recommended.map(markCurrent),
234
- rest: snapshot.data.rest.map(markCurrent)
235
- } : null,
290
+ data,
291
+ history,
236
292
  applyingTag: null,
237
293
  successTag: tag,
238
294
  previousTag: previousTag === tag ? snapshot.previousTag : previousTag,
@@ -301,6 +357,10 @@ const messages = {
301
357
  "panel.strategy.price": "价格优先",
302
358
  "panel.strategy.speed": "速度优先",
303
359
  "panel.strategy.context": "上下文优先",
360
+ "panel.updatedJustNow": "数据刚刚更新",
361
+ "panel.updatedMinutesAgo": "数据更新于 {minutes} 分钟前",
362
+ "panel.history.title": "切换历史",
363
+ "panel.history.revert": "切回",
304
364
  "settings.title": "OpenRouter 供应商",
305
365
  "settings.summary": "推荐数量、综合最佳权重与全局价格模型。",
306
366
  "settings.action.weights": "应用综合权重",
@@ -382,6 +442,10 @@ const messages = {
382
442
  "panel.strategy.price": "Price first",
383
443
  "panel.strategy.speed": "Speed first",
384
444
  "panel.strategy.context": "Context first",
445
+ "panel.updatedJustNow": "Updated just now",
446
+ "panel.updatedMinutesAgo": "Updated {minutes} min ago",
447
+ "panel.history.title": "Switch history",
448
+ "panel.history.revert": "Revert",
385
449
  "settings.title": "OpenRouter Providers",
386
450
  "settings.summary": "Recommended count, ranking weights, and price blend.",
387
451
  "settings.action.weights": "Apply ranking weights",
@@ -460,13 +524,20 @@ function useUiI18n() {
460
524
 
461
525
  //#endregion
462
526
  //#region src/client/provider-panel.ts
527
+ const textPrimary = "var(--dsw-alias-label-primary, #f4f4f5)";
528
+ const textSecondary = "var(--dsw-alias-label-secondary, #d4d4d8)";
529
+ const textTertiary = "var(--dsw-alias-label-tertiary, #a1a1aa)";
530
+ const borderL2 = "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))";
531
+ const bgLayer1 = "var(--dsw-alias-bg-layer-1, #18181b)";
532
+ const bgLayer2 = "var(--dsw-alias-bg-layer-2, #27272a)";
533
+ const bgLayer3 = "var(--dsw-alias-bg-layer-3, rgba(255,255,255,.05))";
463
534
  const actionButtonStyle = {
464
535
  height: 36,
465
536
  padding: "0 12px",
466
537
  borderRadius: 8,
467
- border: "1px solid rgba(255,255,255,.14)",
468
- background: "rgba(255,255,255,.06)",
469
- color: "#e4e4e7",
538
+ border: borderL2,
539
+ background: bgLayer3,
540
+ color: textPrimary,
470
541
  cursor: "pointer"
471
542
  };
472
543
  const iconButtonStyle = {
@@ -639,12 +710,17 @@ const panelStyle = {
639
710
  overflow: "hidden",
640
711
  display: "flex",
641
712
  flexDirection: "column",
642
- border: "1px solid rgba(255,255,255,.12)",
713
+ border: borderL2,
643
714
  borderRadius: 16,
644
- background: "#18181b",
645
- color: "#f4f4f5",
646
- boxShadow: "0 24px 80px rgba(0,0,0,.5)"
715
+ background: bgLayer1,
716
+ color: textPrimary,
717
+ boxShadow: "0 24px 80px rgba(0,0,0,.5)",
718
+ outline: "none"
647
719
  };
720
+ function freshnessLabel(updatedAt, now, i18n = createI18n("zh-CN")) {
721
+ const minutes = Math.max(0, Math.floor((now - updatedAt) / 6e4));
722
+ return minutes === 0 ? i18n.t("panel.updatedJustNow") : i18n.t("panel.updatedMinutesAgo", { minutes });
723
+ }
648
724
  const providerGridColumns = "minmax(110px,1fr) minmax(0,2fr) minmax(54px,auto)";
649
725
  function ProviderOverlay(props) {
650
726
  const i18n = useUiI18n();
@@ -655,6 +731,8 @@ function ProviderOverlay(props) {
655
731
  current,
656
732
  currentAddress
657
733
  });
734
+ const dialogRef = (0, react.useRef)(null);
735
+ const [now, setNow] = (0, react.useState)(() => Date.now());
658
736
  (0, react.useEffect)(() => {
659
737
  if (!state.open) return;
660
738
  if (!target.sessionId) props.store.close();
@@ -665,7 +743,41 @@ function ProviderOverlay(props) {
665
743
  target.sessionId,
666
744
  props.store
667
745
  ]);
746
+ (0, react.useEffect)(() => {
747
+ if (!state.open) return;
748
+ setNow(Date.now());
749
+ const timer = setInterval(() => setNow(Date.now()), 3e4);
750
+ return () => clearInterval(timer);
751
+ }, [state.open, state.updatedAt]);
752
+ (0, react.useEffect)(() => {
753
+ if (!state.open) return;
754
+ const previous = document.activeElement;
755
+ dialogRef.current?.focus();
756
+ return () => {
757
+ if (previous instanceof HTMLElement) previous.focus();
758
+ };
759
+ }, [state.open]);
668
760
  if (!state.open) return null;
761
+ const onKeyDown = (event) => {
762
+ if (event.key === "Escape") {
763
+ event.stopPropagation();
764
+ props.store.close();
765
+ return;
766
+ }
767
+ if (event.key !== "Tab" || !dialogRef.current) return;
768
+ const focusable = Array.from(dialogRef.current.querySelectorAll("button:not(:disabled), select:not(:disabled), input:not(:disabled), summary, [tabindex]:not([tabindex=\"-1\"])"));
769
+ if (focusable.length === 0) return;
770
+ const first = focusable[0];
771
+ const last = focusable.at(-1);
772
+ const active = document.activeElement;
773
+ if (event.shiftKey && (active === first || active === dialogRef.current)) {
774
+ event.preventDefault();
775
+ last.focus();
776
+ } else if (!event.shiftKey && active === last) {
777
+ event.preventDefault();
778
+ first.focus();
779
+ }
780
+ };
669
781
  const rows = state.data ? providerRows(state.data, i18n) : [];
670
782
  const credentialReady = state.credential?.configured === true;
671
783
  const columns = providerTableColumns(i18n);
@@ -674,13 +786,16 @@ function ProviderOverlay(props) {
674
786
  return (0, react.createElement)("div", {
675
787
  style: backdrop,
676
788
  role: "presentation",
789
+ onKeyDown,
677
790
  onMouseDown: (event) => {
678
791
  if (event.target === event.currentTarget) props.store.close();
679
792
  }
680
793
  }, (0, react.createElement)("section", {
794
+ ref: dialogRef,
681
795
  role: "dialog",
682
796
  "aria-modal": true,
683
797
  "aria-label": i18n.t("panel.title"),
798
+ tabIndex: -1,
684
799
  style: panelStyle
685
800
  }, (0, react.createElement)("header", { style: {
686
801
  display: "flex",
@@ -688,7 +803,7 @@ function ProviderOverlay(props) {
688
803
  justifyContent: "space-between",
689
804
  gap: 16,
690
805
  padding: "18px 22px 14px",
691
- borderBottom: "1px solid rgba(255,255,255,.1)"
806
+ borderBottom: borderL2
692
807
  } }, (0, react.createElement)("div", { style: {
693
808
  minWidth: 0,
694
809
  flex: 1
@@ -698,7 +813,7 @@ function ProviderOverlay(props) {
698
813
  } }, i18n.t("panel.title")), (0, react.createElement)("label", { style: {
699
814
  display: "block",
700
815
  margin: "10px 0 5px",
701
- color: "#71717a",
816
+ color: textTertiary,
702
817
  fontSize: 10,
703
818
  fontWeight: 600,
704
819
  letterSpacing: ".06em",
@@ -711,9 +826,9 @@ function ProviderOverlay(props) {
711
826
  width: "min(420px, 100%)",
712
827
  padding: "8px 10px",
713
828
  borderRadius: 8,
714
- border: "1px solid rgba(255,255,255,.16)",
715
- background: "#27272a",
716
- color: "#f4f4f5"
829
+ border: borderL2,
830
+ background: bgLayer2,
831
+ color: textPrimary
717
832
  },
718
833
  onChange: (event) => {
719
834
  if (!target.sessionId) return;
@@ -735,7 +850,7 @@ function ProviderOverlay(props) {
735
850
  marginTop: 10,
736
851
  padding: 3,
737
852
  borderRadius: 9,
738
- background: "rgba(255,255,255,.05)"
853
+ background: bgLayer3
739
854
  }
740
855
  }, ...STRATEGY_OPTIONS.map((option) => {
741
856
  const active = state.strategy === option.id;
@@ -754,7 +869,7 @@ function ProviderOverlay(props) {
754
869
  borderRadius: 6,
755
870
  padding: "5px 8px",
756
871
  background: active ? "rgba(37,99,235,.15)" : "transparent",
757
- color: active ? "#93c5fd" : "#d4d4d8",
872
+ color: active ? "#93c5fd" : textSecondary,
758
873
  cursor: active ? "default" : "pointer",
759
874
  whiteSpace: "nowrap",
760
875
  fontSize: 12
@@ -837,18 +952,21 @@ function ProviderOverlay(props) {
837
952
  }
838
953
  }, i18n.t("panel.undo"))) : null, state.status === "loading" && rows.length === 0 ? (0, react.createElement)("p", { style: {
839
954
  padding: 22,
840
- color: "#a1a1aa"
955
+ color: textTertiary
841
956
  } }, i18n.t("panel.loading")) : null, credentialReady && rows.length > 0 ? (0, react.createElement)("div", { style: {
842
957
  display: "grid",
843
958
  gap: 2,
844
959
  margin: "12px 22px 0",
845
- color: "#71717a",
960
+ color: textTertiary,
846
961
  fontSize: 11,
847
962
  lineHeight: 1.5
848
963
  } }, (0, react.createElement)("span", null, i18n.t("panel.switchHint")), (0, react.createElement)("span", { style: {
849
- color: "#52525b",
850
- fontSize: 10
851
- } }, i18n.t("panel.uptimeDelayHint"))) : null, (0, react.createElement)("div", { style: {
964
+ fontSize: 10,
965
+ opacity: .8
966
+ } }, i18n.t("panel.uptimeDelayHint")), state.updatedAt === null ? null : (0, react.createElement)("span", { style: {
967
+ fontSize: 10,
968
+ opacity: .8
969
+ } }, freshnessLabel(state.updatedAt, now, i18n))) : null, (0, react.createElement)("div", { style: {
852
970
  overflowY: "auto",
853
971
  padding: "12px 14px 20px"
854
972
  } }, ...rows.flatMap((row) => {
@@ -859,7 +977,7 @@ function ProviderOverlay(props) {
859
977
  key: `group-${row.group}`,
860
978
  style: {
861
979
  margin: "14px 8px 8px",
862
- color: "#a1a1aa",
980
+ color: textTertiary,
863
981
  fontSize: 12,
864
982
  textTransform: "uppercase",
865
983
  letterSpacing: ".06em"
@@ -872,7 +990,7 @@ function ProviderOverlay(props) {
872
990
  gridTemplateColumns: providerGridColumns,
873
991
  gap: 14,
874
992
  padding: "0 14px 8px",
875
- color: "#71717a",
993
+ color: textTertiary,
876
994
  fontSize: 11,
877
995
  letterSpacing: ".04em",
878
996
  textTransform: "uppercase"
@@ -898,8 +1016,8 @@ function ProviderOverlay(props) {
898
1016
  padding: "12px 14px",
899
1017
  marginBottom: 6,
900
1018
  borderRadius: 10,
901
- border: row.current ? "1px solid #3b82f6" : "1px solid rgba(255,255,255,.12)",
902
- background: row.current ? "rgba(37,99,235,.14)" : "rgba(255,255,255,.045)",
1019
+ border: row.current ? "1px solid #3b82f6" : borderL2,
1020
+ background: row.current ? "rgba(37,99,235,.14)" : bgLayer3,
903
1021
  color: "inherit",
904
1022
  textAlign: "left",
905
1023
  cursor: row.current ? "default" : "pointer",
@@ -929,7 +1047,7 @@ function ProviderOverlay(props) {
929
1047
  } }, (0, react.createElement)("span", {
930
1048
  title: row.detail,
931
1049
  style: {
932
- color: "#a1a1aa",
1050
+ color: textTertiary,
933
1051
  fontSize: 12,
934
1052
  overflow: "hidden",
935
1053
  textOverflow: "ellipsis",
@@ -938,7 +1056,7 @@ function ProviderOverlay(props) {
938
1056
  }, row.detail), (0, react.createElement)("span", {
939
1057
  title: row.priceLabel,
940
1058
  style: {
941
- color: "#d4d4d8",
1059
+ color: textSecondary,
942
1060
  fontSize: 11,
943
1061
  overflow: "hidden",
944
1062
  textOverflow: "ellipsis",
@@ -952,7 +1070,7 @@ function ProviderOverlay(props) {
952
1070
  title: row.breakdown,
953
1071
  "aria-label": `${row.scoreLabel}; ${row.breakdown}`,
954
1072
  style: {
955
- color: row.current ? "#60a5fa" : "#d4d4d8",
1073
+ color: row.current ? "#60a5fa" : textSecondary,
956
1074
  fontSize: 12,
957
1075
  textAlign: "right",
958
1076
  textDecoration: "underline dotted rgba(161,161,170,.6)",
@@ -960,7 +1078,62 @@ function ProviderOverlay(props) {
960
1078
  }
961
1079
  }, switching ? i18n.t("panel.switching") : switched ? i18n.t("panel.switched") : row.scoreLabel)));
962
1080
  return nodes;
963
- }))));
1081
+ }), state.history.length === 0 ? null : (0, react.createElement)("details", { style: {
1082
+ margin: "10px 8px 0",
1083
+ padding: "10px 12px",
1084
+ borderRadius: 10,
1085
+ border: borderL2
1086
+ } }, (0, react.createElement)("summary", { style: {
1087
+ cursor: "pointer",
1088
+ color: textTertiary,
1089
+ fontSize: 12,
1090
+ textTransform: "uppercase",
1091
+ letterSpacing: ".06em"
1092
+ } }, i18n.t("panel.history.title")), ...state.history.map((item) => {
1093
+ const revertable = item.fromTag !== null && state.data?.openrouterModel === item.model && state.data.currentTag !== item.fromTag;
1094
+ return (0, react.createElement)("div", {
1095
+ key: `${item.at}-${item.toTag}`,
1096
+ title: item.model,
1097
+ style: {
1098
+ display: "flex",
1099
+ alignItems: "center",
1100
+ gap: 10,
1101
+ padding: "8px 0",
1102
+ borderTop: borderL2,
1103
+ fontSize: 12,
1104
+ marginTop: 8
1105
+ }
1106
+ }, (0, react.createElement)("span", { style: {
1107
+ color: textTertiary,
1108
+ flex: "0 0 auto"
1109
+ } }, new Date(item.at).toLocaleTimeString(i18n.locale, {
1110
+ hour: "2-digit",
1111
+ minute: "2-digit"
1112
+ })), (0, react.createElement)("span", { style: {
1113
+ color: textSecondary,
1114
+ minWidth: 0,
1115
+ overflow: "hidden",
1116
+ textOverflow: "ellipsis",
1117
+ whiteSpace: "nowrap"
1118
+ } }, `${item.fromName ?? item.fromTag ?? "—"} → ${item.toName}`), item.fromTps !== null && item.toTps !== null ? (0, react.createElement)("span", { style: {
1119
+ color: textTertiary,
1120
+ flex: "0 0 auto"
1121
+ } }, `${item.fromTps} → ${item.toTps} t/s`) : null, revertable ? (0, react.createElement)("button", {
1122
+ type: "button",
1123
+ disabled: state.status === "applying",
1124
+ onClick: () => {
1125
+ if (target.sessionId && item.fromTag) consume(props.store.apply(target.sessionId, item.fromTag));
1126
+ },
1127
+ style: {
1128
+ ...actionButtonStyle,
1129
+ height: 26,
1130
+ padding: "0 10px",
1131
+ marginLeft: "auto",
1132
+ fontSize: 12,
1133
+ whiteSpace: "nowrap"
1134
+ }
1135
+ }, i18n.t("panel.history.revert")) : null);
1136
+ })))));
964
1137
  }
965
1138
 
966
1139
  //#endregion
package/lib/index.js CHANGED
@@ -104,8 +104,8 @@ async function applyProvider(deps, input) {
104
104
  reasoningEfforts: effort.declaration,
105
105
  openrouterModel: input.openrouterModel
106
106
  };
107
- const index = models.findIndex((model) => model.id === modelId);
108
- const next = [...models];
107
+ const next = models.filter((model) => model.id === modelId || !(model.id.startsWith("@preset/") && model.openrouterModel === input.openrouterModel));
108
+ const index = next.findIndex((model) => model.id === modelId);
109
109
  if (index >= 0) next[index] = {
110
110
  ...next[index],
111
111
  ...entry
@@ -1,5 +1,5 @@
1
1
  export type SupportedLocale = 'zh-CN' | 'en-US';
2
- export type TranslationKey = 'panel.title' | 'panel.currentModelLoading' | 'panel.modelSelect' | 'panel.strategyGroup' | 'panel.refresh' | 'panel.close' | 'panel.loading' | 'panel.titleTrigger' | 'panel.unavailable' | 'panel.group.recommended' | 'panel.group.rest' | 'panel.column.provider' | 'panel.column.details' | 'panel.column.score' | 'panel.current' | 'panel.credentialMissing' | 'panel.switchHint' | 'panel.uptime' | 'panel.uptimeDelayHint' | 'panel.refreshing' | 'panel.switching' | 'panel.switched' | 'panel.switchAction' | 'panel.insight.quantization' | 'panel.insight.speed' | 'panel.insight.price' | 'panel.insight.context' | 'panel.currentModel' | 'panel.price.input' | 'panel.price.output' | 'panel.price.cache' | 'panel.savings' | 'panel.undo' | 'panel.switchSuccess' | 'panel.strategy.balanced' | 'panel.strategy.price' | 'panel.strategy.speed' | 'panel.strategy.context' | 'settings.title' | 'settings.summary' | 'settings.action.weights' | 'settings.action.priceBlend' | 'settings.action.reset' | 'settings.action.lock' | 'settings.action.unlock' | 'settings.expand' | 'settings.collapse' | 'settings.row.minContextTokens' | 'settings.row.recommendedCount' | 'settings.row.cacheTtlMs' | 'settings.row.weights.quantization' | 'settings.row.weights.speed' | 'settings.row.weights.price' | 'settings.row.weights.context' | 'settings.row.priceBlend.input' | 'settings.row.priceBlend.output' | 'settings.row.priceBlend.cache' | 'settings.row.uptimePenaltyThreshold' | 'settings.row.uptimePenaltyFactor' | 'settings.group.weights.title' | 'settings.group.weights.scope' | 'settings.group.priceBlend.title' | 'settings.group.priceBlend.scope' | 'settings.group.advanced.title' | 'settings.group.strategies.title' | 'settings.group.strategies.scope' | 'settings.dimension.quantization' | 'settings.dimension.speed' | 'settings.dimension.price' | 'settings.dimension.context' | 'settings.dimension.input' | 'settings.dimension.output' | 'settings.dimension.cache' | 'settings.connection.title' | 'settings.connection.loading' | 'settings.connection.configured' | 'settings.connection.missing' | 'settings.connection.error' | 'settings.connection.guide';
2
+ export type TranslationKey = 'panel.title' | 'panel.currentModelLoading' | 'panel.modelSelect' | 'panel.strategyGroup' | 'panel.refresh' | 'panel.close' | 'panel.loading' | 'panel.titleTrigger' | 'panel.unavailable' | 'panel.group.recommended' | 'panel.group.rest' | 'panel.column.provider' | 'panel.column.details' | 'panel.column.score' | 'panel.current' | 'panel.credentialMissing' | 'panel.switchHint' | 'panel.uptime' | 'panel.uptimeDelayHint' | 'panel.refreshing' | 'panel.switching' | 'panel.switched' | 'panel.switchAction' | 'panel.insight.quantization' | 'panel.insight.speed' | 'panel.insight.price' | 'panel.insight.context' | 'panel.currentModel' | 'panel.price.input' | 'panel.price.output' | 'panel.price.cache' | 'panel.savings' | 'panel.undo' | 'panel.switchSuccess' | 'panel.strategy.balanced' | 'panel.strategy.price' | 'panel.strategy.speed' | 'panel.strategy.context' | 'panel.updatedJustNow' | 'panel.updatedMinutesAgo' | 'panel.history.title' | 'panel.history.revert' | 'settings.title' | 'settings.summary' | 'settings.action.weights' | 'settings.action.priceBlend' | 'settings.action.reset' | 'settings.action.lock' | 'settings.action.unlock' | 'settings.expand' | 'settings.collapse' | 'settings.row.minContextTokens' | 'settings.row.recommendedCount' | 'settings.row.cacheTtlMs' | 'settings.row.weights.quantization' | 'settings.row.weights.speed' | 'settings.row.weights.price' | 'settings.row.weights.context' | 'settings.row.priceBlend.input' | 'settings.row.priceBlend.output' | 'settings.row.priceBlend.cache' | 'settings.row.uptimePenaltyThreshold' | 'settings.row.uptimePenaltyFactor' | 'settings.group.weights.title' | 'settings.group.weights.scope' | 'settings.group.priceBlend.title' | 'settings.group.priceBlend.scope' | 'settings.group.advanced.title' | 'settings.group.strategies.title' | 'settings.group.strategies.scope' | 'settings.dimension.quantization' | 'settings.dimension.speed' | 'settings.dimension.price' | 'settings.dimension.context' | 'settings.dimension.input' | 'settings.dimension.output' | 'settings.dimension.cache' | 'settings.connection.title' | 'settings.connection.loading' | 'settings.connection.configured' | 'settings.connection.missing' | 'settings.connection.error' | 'settings.connection.guide';
3
3
  export declare const zh: Record<TranslationKey, string>;
4
4
  export declare const en: Record<TranslationKey, string>;
5
5
  export declare const LOCALE_NS = "openrouterProviders";
@@ -22,6 +22,17 @@ export interface ProviderPanelSnapshot {
22
22
  ref: string;
23
23
  source: 'provider' | 'fallback';
24
24
  } | null;
25
+ history: SwitchHistoryEntry[];
26
+ }
27
+ export interface SwitchHistoryEntry {
28
+ at: number;
29
+ model: string;
30
+ fromTag: string | null;
31
+ fromName: string | null;
32
+ fromTps: number | null;
33
+ toTag: string;
34
+ toName: string;
35
+ toTps: number | null;
25
36
  }
26
37
  export interface ConfiguredProviderModel {
27
38
  provider: string;
@@ -42,5 +53,6 @@ export interface ProviderPanelStore {
42
53
  apply(sessionId: string, tag: string): Promise<void>;
43
54
  dispose(): void;
44
55
  }
45
- export declare function createProviderPanelStore(fetchImpl?: typeof fetch): ProviderPanelStore;
56
+ export type PanelStorage = Pick<Storage, 'getItem' | 'setItem'>;
57
+ export declare function createProviderPanelStore(fetchImpl?: typeof fetch, storage?: PanelStorage | undefined): ProviderPanelStore;
46
58
  //# sourceMappingURL=provider-panel-store.d.ts.map
@@ -56,6 +56,10 @@ export declare function ProviderSidebarTrigger(props: GlobalSlotProps & {
56
56
  wide: boolean;
57
57
  store: ProviderPanelStore;
58
58
  }): ReactNode;
59
+ export declare function freshnessLabel(updatedAt: number, now: number, i18n?: {
60
+ locale: import("./i18n.js").SupportedLocale;
61
+ t(key: import("./i18n.js").TranslationKey, params?: Record<string, string | number>): string;
62
+ }): string;
59
63
  export declare const providerGridColumns = "minmax(110px,1fr) minmax(0,2fr) minmax(54px,auto)";
60
64
  export declare function ProviderOverlay(props: GlobalSlotProps & {
61
65
  store: ProviderPanelStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-openrouter-provider-advisor",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DSH web plugin that ranks OpenRouter upstream providers by quality, speed, cache-heavy price, context, and uptime, then safely switches the active route.",
5
5
  "keywords": [
6
6
  "deepseek-harness",