dsh-openrouter-provider-advisor 0.1.0 → 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 ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
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
+
28
+ ## [0.1.1] - 2026-08-24
29
+
30
+ ### Added
31
+
32
+ - Explainable recommendation badges and per-dimension score breakdowns.
33
+ - Explicit input, output, and cache pricing in the provider list.
34
+ - Cache-heavy estimated savings compared with the current provider.
35
+ - One-click switching back to the previous provider.
36
+
37
+ ### Changed
38
+
39
+ - Renamed the panel to “OpenRouter Provider Recommendations” / “OpenRouter 供应商推荐”.
40
+ - Polished refresh, close, provider-row, loading, success, and failure interactions.
41
+ - Replaced abrupt uptime safety bands with a continuous mild penalty curve, while retaining stronger penalties for genuinely unreliable endpoints.
42
+ - Updated the README provider-panel screenshot.
43
+
44
+ ### Validation
45
+
46
+ - 75 automated tests passing.
47
+ - TypeScript typecheck and production build passing.
48
+ - `npm audit --omit=dev` reports zero vulnerabilities.
49
+
50
+ ## [0.1.0] - 2026-08-24
51
+
52
+ - Initial public release.
53
+
54
+ [0.1.2]: https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/compare/v0.1.1...v0.1.2
55
+ [0.1.1]: https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/compare/v0.1.0...v0.1.1
56
+ [0.1.0]: https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/releases/tag/v0.1.0
Binary file
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,9 +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
- credential: null
78
+ credential: null,
79
+ applyingTag: null,
80
+ successTag: null,
81
+ updatedAt: null,
82
+ previousTag: null,
83
+ history: readHistory(storage)
46
84
  };
47
85
  const listeners = /* @__PURE__ */ new Set();
48
86
  let generation = 0;
@@ -90,14 +128,16 @@ function createProviderPanelStore(fetchImpl = fetch) {
90
128
  publish({
91
129
  status: "ready",
92
130
  data,
93
- error: null
131
+ error: null,
132
+ updatedAt: Date.now()
94
133
  });
95
134
  } catch (error) {
96
135
  if (operation.id !== generation || operation.signal.aborted) return;
136
+ const message = error instanceof Error ? error.message : String(error);
97
137
  publish({
98
138
  status: "error",
99
- data: null,
100
- error: error instanceof Error ? error.message : String(error)
139
+ data: refresh ? snapshot.data : null,
140
+ error: message
101
141
  });
102
142
  throw error;
103
143
  }
@@ -118,12 +158,15 @@ function createProviderPanelStore(fetchImpl = fetch) {
118
158
  open: true,
119
159
  status: "loading",
120
160
  sessionId,
121
- strategy: "balanced",
122
161
  data: null,
123
162
  models: [],
124
163
  selected: null,
125
164
  credential: null,
126
- error: null
165
+ error: null,
166
+ applyingTag: null,
167
+ successTag: null,
168
+ updatedAt: null,
169
+ previousTag: null
127
170
  });
128
171
  try {
129
172
  const catalog = await responseJson(await fetchImpl(`/api/openrouter-providers/models?sessionId=${encodeURIComponent(sessionId)}`, { signal: operation.signal }));
@@ -147,7 +190,7 @@ function createProviderPanelStore(fetchImpl = fetch) {
147
190
  });
148
191
  return;
149
192
  }
150
- await load(sessionId, false, selected, "balanced");
193
+ await load(sessionId, false, selected);
151
194
  } catch (error) {
152
195
  if (operation.id !== generation || operation.signal.aborted) return;
153
196
  publish({
@@ -165,7 +208,6 @@ function createProviderPanelStore(fetchImpl = fetch) {
165
208
  publish({
166
209
  open: false,
167
210
  status: snapshot.data ? "ready" : "idle",
168
- strategy: "balanced",
169
211
  error: null
170
212
  });
171
213
  },
@@ -183,16 +225,22 @@ function createProviderPanelStore(fetchImpl = fetch) {
183
225
  await load(sessionId, false, selected);
184
226
  },
185
227
  async setStrategy(sessionId, strategy) {
228
+ try {
229
+ storage?.setItem(STRATEGY_KEY, strategy);
230
+ } catch {}
186
231
  publish({ strategy });
187
232
  await load(sessionId, false, snapshot.selected, strategy);
188
233
  },
189
234
  async apply(sessionId, tag) {
190
235
  const operation = begin();
236
+ const previousTag = snapshot.data?.currentTag ?? null;
191
237
  publish({
192
238
  open: true,
193
239
  status: "applying",
194
240
  sessionId,
195
- error: null
241
+ error: null,
242
+ applyingTag: tag,
243
+ successTag: null
196
244
  });
197
245
  try {
198
246
  await responseJson(await fetchImpl("/api/openrouter-providers/apply", {
@@ -207,13 +255,52 @@ function createProviderPanelStore(fetchImpl = fetch) {
207
255
  signal: operation.signal
208
256
  }));
209
257
  if (operation.id !== generation) return;
210
- store.close();
258
+ const markCurrent = (row) => ({
259
+ ...row,
260
+ current: row.tag === tag
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
+ }
287
+ publish({
288
+ open: true,
289
+ status: "ready",
290
+ data,
291
+ history,
292
+ applyingTag: null,
293
+ successTag: tag,
294
+ previousTag: previousTag === tag ? snapshot.previousTag : previousTag,
295
+ error: null
296
+ });
211
297
  } catch (error) {
212
298
  if (operation.id !== generation || operation.signal.aborted) return;
213
299
  publish({
214
300
  open: true,
215
301
  status: "error",
216
- error: error instanceof Error ? error.message : String(error)
302
+ error: error instanceof Error ? error.message : String(error),
303
+ applyingTag: null
217
304
  });
218
305
  throw error;
219
306
  }
@@ -232,7 +319,7 @@ function createProviderPanelStore(fetchImpl = fetch) {
232
319
  //#region src/client/i18n.ts
233
320
  const messages = {
234
321
  "zh-CN": {
235
- "panel.title": "OpenRouter 供应商",
322
+ "panel.title": "OpenRouter 供应商推荐",
236
323
  "panel.currentModelLoading": "正在读取当前模型…",
237
324
  "panel.modelSelect": "选择 DSH 模型",
238
325
  "panel.strategyGroup": "推荐策略",
@@ -244,17 +331,36 @@ const messages = {
244
331
  "panel.group.recommended": "推荐 Top 5",
245
332
  "panel.group.rest": "其他合格供应商",
246
333
  "panel.column.provider": "供应商",
247
- "panel.column.details": "规格详情",
334
+ "panel.column.details": "性能与价格",
248
335
  "panel.column.score": "分数",
249
336
  "panel.current": "当前",
250
337
  "panel.credentialMissing": "未检测到 OpenRouter 凭据“{ref}”。请前往 DSH 设置 → 模型完成配置;推荐、刷新和供应商切换暂不可用。",
251
338
  "panel.switchHint": "点击供应商即可实时切换;当前会话与默认模型会立即更新。",
252
339
  "panel.uptime": "可用率",
253
- "panel.uptimeDelayHint": "可用率来自 OpenRouter 近 30 分钟统计,故障与恢复可能存在检测延迟。",
340
+ "panel.uptimeDelayHint": "可用率来自 OpenRouter 近 30 分钟统计并采用连续温和降权;故障与恢复可能存在检测延迟。",
341
+ "panel.refreshing": "正在刷新",
342
+ "panel.switching": "正在切换…",
343
+ "panel.switched": "已切换",
344
+ "panel.switchAction": "切换",
345
+ "panel.insight.quantization": "量化突出",
346
+ "panel.insight.speed": "速度突出",
347
+ "panel.insight.price": "价格突出",
348
+ "panel.insight.context": "上下文突出",
349
+ "panel.currentModel": "当前模型",
350
+ "panel.price.input": "输入",
351
+ "panel.price.output": "输出",
352
+ "panel.price.cache": "缓存",
353
+ "panel.savings": "预计节省 {percent}%",
354
+ "panel.undo": "切回上一个",
355
+ "panel.switchSuccess": "供应商已切换,可立即切回上一个选择。",
254
356
  "panel.strategy.balanced": "综合最佳",
255
357
  "panel.strategy.price": "价格优先",
256
358
  "panel.strategy.speed": "速度优先",
257
359
  "panel.strategy.context": "上下文优先",
360
+ "panel.updatedJustNow": "数据刚刚更新",
361
+ "panel.updatedMinutesAgo": "数据更新于 {minutes} 分钟前",
362
+ "panel.history.title": "切换历史",
363
+ "panel.history.revert": "切回",
258
364
  "settings.title": "OpenRouter 供应商",
259
365
  "settings.summary": "推荐数量、综合最佳权重与全局价格模型。",
260
366
  "settings.action.weights": "应用综合权重",
@@ -280,7 +386,7 @@ const messages = {
280
386
  "settings.group.weights.scope": "仅影响“综合最佳”推荐策略;调整一项时,其他未锁定项会按比例联动。",
281
387
  "settings.group.priceBlend.title": "价格计算占比",
282
388
  "settings.group.priceBlend.scope": "所有推荐策略共用。Code Agent 默认按 95% 以上缓存流量优化。",
283
- "settings.group.advanced.title": "筛选与可靠性",
389
+ "settings.group.advanced.title": "筛选与可靠性(阈值以下 5% 区间渐进降权)",
284
390
  "settings.group.strategies.title": "查看其他策略权重",
285
391
  "settings.group.strategies.scope": "以下策略使用内置权重;价格维度仍使用上方全局价格占比。",
286
392
  "settings.dimension.quantization": "量化",
@@ -298,7 +404,7 @@ const messages = {
298
404
  "settings.connection.guide": "请前往 DSH 设置 → 模型,为 OpenRouter provider 配置 API Key。插件不会读取或显示密钥内容。"
299
405
  },
300
406
  "en-US": {
301
- "panel.title": "OpenRouter Providers",
407
+ "panel.title": "OpenRouter Provider Recommendations",
302
408
  "panel.currentModelLoading": "Loading current model…",
303
409
  "panel.modelSelect": "Select DSH model",
304
410
  "panel.strategyGroup": "Ranking strategy",
@@ -310,17 +416,36 @@ const messages = {
310
416
  "panel.group.recommended": "Top 5 Recommendations",
311
417
  "panel.group.rest": "Other Eligible Providers",
312
418
  "panel.column.provider": "Provider",
313
- "panel.column.details": "Specs & Performance",
419
+ "panel.column.details": "Performance & price",
314
420
  "panel.column.score": "Score",
315
421
  "panel.current": "Current",
316
422
  "panel.credentialMissing": "OpenRouter credential “{ref}” is not configured. Configure it in DSH Settings → Models; recommendations, refresh, and provider switching are disabled for now.",
317
423
  "panel.switchHint": "Click a provider to switch immediately; the current session and default model update together.",
318
424
  "panel.uptime": "uptime",
319
- "panel.uptimeDelayHint": "Uptime comes from OpenRouter's trailing 30-minute statistics, so failures and recoveries may be detected with a delay.",
425
+ "panel.uptimeDelayHint": "Uptime uses a continuous mild penalty over OpenRouter's trailing 30-minute statistics; failures and recoveries may be detected with a delay.",
426
+ "panel.refreshing": "Refreshing",
427
+ "panel.switching": "Switching…",
428
+ "panel.switched": "Switched",
429
+ "panel.switchAction": "Switch",
430
+ "panel.insight.quantization": "Quality strength",
431
+ "panel.insight.speed": "Speed strength",
432
+ "panel.insight.price": "Price strength",
433
+ "panel.insight.context": "Context strength",
434
+ "panel.currentModel": "Current model",
435
+ "panel.price.input": "Input",
436
+ "panel.price.output": "Output",
437
+ "panel.price.cache": "Cache",
438
+ "panel.savings": "Est. {percent}% less",
439
+ "panel.undo": "Switch back",
440
+ "panel.switchSuccess": "Provider switched. You can immediately return to the previous choice.",
320
441
  "panel.strategy.balanced": "Best overall",
321
442
  "panel.strategy.price": "Price first",
322
443
  "panel.strategy.speed": "Speed first",
323
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",
324
449
  "settings.title": "OpenRouter Providers",
325
450
  "settings.summary": "Recommended count, ranking weights, and price blend.",
326
451
  "settings.action.weights": "Apply ranking weights",
@@ -346,7 +471,7 @@ const messages = {
346
471
  "settings.group.weights.scope": "Affects only Best overall. Changing one item proportionally redistributes the other unlocked items.",
347
472
  "settings.group.priceBlend.title": "Price calculation blend",
348
473
  "settings.group.priceBlend.scope": "Shared by all ranking strategies. The Code Agent default is optimized for 95%+ cached traffic.",
349
- "settings.group.advanced.title": "Filtering & reliability",
474
+ "settings.group.advanced.title": "Filtering & reliability (gradual penalty across 5% below threshold)",
350
475
  "settings.group.strategies.title": "View other strategy weights",
351
476
  "settings.group.strategies.scope": "These strategies use built-in weights; their price dimension still uses the global price blend above.",
352
477
  "settings.dimension.quantization": "Quality",
@@ -399,6 +524,67 @@ function useUiI18n() {
399
524
 
400
525
  //#endregion
401
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))";
534
+ const actionButtonStyle = {
535
+ height: 36,
536
+ padding: "0 12px",
537
+ borderRadius: 8,
538
+ border: borderL2,
539
+ background: bgLayer3,
540
+ color: textPrimary,
541
+ cursor: "pointer"
542
+ };
543
+ const iconButtonStyle = {
544
+ ...actionButtonStyle,
545
+ width: 36,
546
+ padding: 0,
547
+ fontSize: 20,
548
+ lineHeight: 1
549
+ };
550
+ function providerInsight(row, i18n = createI18n("zh-CN")) {
551
+ const entries = [
552
+ [
553
+ "quantization",
554
+ row.dimensions.quantization,
555
+ i18n.t("settings.dimension.quantization")
556
+ ],
557
+ [
558
+ "speed",
559
+ row.dimensions.speed,
560
+ i18n.t("settings.dimension.speed")
561
+ ],
562
+ [
563
+ "price",
564
+ row.dimensions.price,
565
+ i18n.t("settings.dimension.price")
566
+ ],
567
+ [
568
+ "context",
569
+ row.dimensions.context,
570
+ i18n.t("settings.dimension.context")
571
+ ]
572
+ ];
573
+ const strongest = entries.reduce((best, item) => item[1] > best[1] ? item : best);
574
+ return {
575
+ badge: i18n.t(`panel.insight.${strongest[0]}`),
576
+ breakdown: entries.map(([, value, label]) => `${label} ${Math.round(value)}`).join(" · ")
577
+ };
578
+ }
579
+ function providerPriceLabel(row, i18n = createI18n("zh-CN")) {
580
+ return `${i18n.t("panel.price.input")} $${row.price.input} · ${i18n.t("panel.price.output")} $${row.price.output} · ${i18n.t("panel.price.cache")} $${row.price.cache} / M tokens`;
581
+ }
582
+ function estimatedSavings(current, candidate) {
583
+ const cost = (price) => price.input * .02 + price.output * .08 + price.cache * .9;
584
+ const baseline = cost(current);
585
+ const saving = baseline > 0 ? Math.round((1 - cost(candidate) / baseline) * 100) : 0;
586
+ return saving > 0 ? saving : null;
587
+ }
402
588
  const STRATEGY_OPTIONS = [
403
589
  {
404
590
  id: "balanced",
@@ -418,14 +604,21 @@ const STRATEGY_OPTIONS = [
418
604
  }
419
605
  ];
420
606
  function providerRows(data, i18n = createI18n("zh-CN")) {
607
+ const currentPrice = [...data.recommended, ...data.rest].find((row) => row.current)?.price;
421
608
  const map = (group) => (row) => ({
422
609
  group,
423
610
  tag: row.tag,
424
611
  providerName: row.providerName,
425
612
  current: row.current,
426
613
  score: row.score,
427
- detail: `${row.quantization} · ${row.tps} t/s · $${row.price.input}/$${row.price.output} · ${row.contextLength.toLocaleString()} ctx · ${row.uptime === null ? "N/A" : `${row.uptime.toFixed(2)}%`} ${i18n.t("panel.uptime")}`,
428
- scoreLabel: row.current ? `${i18n.t("panel.current")} · ${row.score.toFixed(1)}` : row.score.toFixed(1)
614
+ detail: `${row.quantization} · ${row.tps} t/s · ${row.contextLength.toLocaleString()} ctx · ${row.uptime === null ? "N/A" : `${row.uptime.toFixed(2)}%`} ${i18n.t("panel.uptime")}`,
615
+ priceLabel: providerPriceLabel(row, i18n),
616
+ savingsLabel: currentPrice && !row.current ? (() => {
617
+ const value = estimatedSavings(currentPrice, row.price);
618
+ return value === null ? null : i18n.t("panel.savings", { percent: value });
619
+ })() : null,
620
+ scoreLabel: row.current ? `${i18n.t("panel.current")} · ${row.score.toFixed(1)}` : row.score.toFixed(1),
621
+ ...providerInsight(row, i18n)
429
622
  });
430
623
  return [...data.recommended.map(map(i18n.t("panel.group.recommended"))), ...data.rest.map(map(i18n.t("panel.group.rest")))];
431
624
  }
@@ -517,12 +710,17 @@ const panelStyle = {
517
710
  overflow: "hidden",
518
711
  display: "flex",
519
712
  flexDirection: "column",
520
- border: "1px solid rgba(255,255,255,.12)",
713
+ border: borderL2,
521
714
  borderRadius: 16,
522
- background: "#18181b",
523
- color: "#f4f4f5",
524
- 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"
525
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
+ }
526
724
  const providerGridColumns = "minmax(110px,1fr) minmax(0,2fr) minmax(54px,auto)";
527
725
  function ProviderOverlay(props) {
528
726
  const i18n = useUiI18n();
@@ -533,6 +731,8 @@ function ProviderOverlay(props) {
533
731
  current,
534
732
  currentAddress
535
733
  });
734
+ const dialogRef = (0, react.useRef)(null);
735
+ const [now, setNow] = (0, react.useState)(() => Date.now());
536
736
  (0, react.useEffect)(() => {
537
737
  if (!state.open) return;
538
738
  if (!target.sessionId) props.store.close();
@@ -543,7 +743,41 @@ function ProviderOverlay(props) {
543
743
  target.sessionId,
544
744
  props.store
545
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]);
546
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
+ };
547
781
  const rows = state.data ? providerRows(state.data, i18n) : [];
548
782
  const credentialReady = state.credential?.configured === true;
549
783
  const columns = providerTableColumns(i18n);
@@ -552,39 +786,49 @@ function ProviderOverlay(props) {
552
786
  return (0, react.createElement)("div", {
553
787
  style: backdrop,
554
788
  role: "presentation",
789
+ onKeyDown,
555
790
  onMouseDown: (event) => {
556
791
  if (event.target === event.currentTarget) props.store.close();
557
792
  }
558
793
  }, (0, react.createElement)("section", {
794
+ ref: dialogRef,
559
795
  role: "dialog",
560
796
  "aria-modal": true,
561
797
  "aria-label": i18n.t("panel.title"),
798
+ tabIndex: -1,
562
799
  style: panelStyle
563
800
  }, (0, react.createElement)("header", { style: {
564
801
  display: "flex",
565
802
  alignItems: "start",
566
803
  justifyContent: "space-between",
567
- padding: "20px 22px 16px",
568
- borderBottom: "1px solid rgba(255,255,255,.1)"
569
- } }, (0, react.createElement)("div", null, (0, react.createElement)("h2", { style: {
804
+ gap: 16,
805
+ padding: "18px 22px 14px",
806
+ borderBottom: borderL2
807
+ } }, (0, react.createElement)("div", { style: {
808
+ minWidth: 0,
809
+ flex: 1
810
+ } }, (0, react.createElement)("h2", { style: {
570
811
  margin: 0,
571
812
  fontSize: 20
572
- } }, i18n.t("panel.title")), (0, react.createElement)("p", { style: {
573
- margin: "6px 0 10px",
574
- color: "#a1a1aa",
575
- fontSize: 13
576
- } }, state.data ? state.data.openrouterModel : i18n.t("panel.currentModelLoading")), (0, react.createElement)("select", {
813
+ } }, i18n.t("panel.title")), (0, react.createElement)("label", { style: {
814
+ display: "block",
815
+ margin: "10px 0 5px",
816
+ color: textTertiary,
817
+ fontSize: 10,
818
+ fontWeight: 600,
819
+ letterSpacing: ".06em",
820
+ textTransform: "uppercase"
821
+ } }, i18n.t("panel.currentModel")), (0, react.createElement)("select", {
577
822
  "aria-label": i18n.t("panel.modelSelect"),
578
823
  value: selectedValue,
579
824
  disabled: !credentialReady || state.models.length === 0 || state.status === "applying",
580
825
  style: {
581
- minWidth: 300,
582
- maxWidth: "min(460px, 60vw)",
583
- padding: "7px 10px",
826
+ width: "min(420px, 100%)",
827
+ padding: "8px 10px",
584
828
  borderRadius: 8,
585
- border: "1px solid rgba(255,255,255,.16)",
586
- background: "#27272a",
587
- color: "#f4f4f5"
829
+ border: borderL2,
830
+ background: bgLayer2,
831
+ color: textPrimary
588
832
  },
589
833
  onChange: (event) => {
590
834
  if (!target.sessionId) return;
@@ -601,12 +845,12 @@ function ProviderOverlay(props) {
601
845
  style: {
602
846
  display: "grid",
603
847
  gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
604
- gap: 6,
605
- width: "min(560px, 70vw)",
606
- marginTop: 12,
607
- padding: 4,
608
- borderRadius: 10,
609
- background: "rgba(255,255,255,.05)"
848
+ gap: 3,
849
+ width: "min(520px, 100%)",
850
+ marginTop: 10,
851
+ padding: 3,
852
+ borderRadius: 9,
853
+ background: bgLayer3
610
854
  }
611
855
  }, ...STRATEGY_OPTIONS.map((option) => {
612
856
  const active = state.strategy === option.id;
@@ -618,14 +862,17 @@ function ProviderOverlay(props) {
618
862
  onClick: () => {
619
863
  if (target.sessionId && !active) consume(props.store.setStrategy(target.sessionId, option.id));
620
864
  },
865
+ title: i18n.t(option.labelKey),
621
866
  style: {
867
+ minHeight: 38,
622
868
  border: active ? "1px solid #3b82f6" : "1px solid transparent",
623
- borderRadius: 7,
624
- padding: "7px 10px",
625
- background: active ? "rgba(37,99,235,.2)" : "transparent",
626
- color: active ? "#93c5fd" : "#d4d4d8",
869
+ borderRadius: 6,
870
+ padding: "5px 8px",
871
+ background: active ? "rgba(37,99,235,.15)" : "transparent",
872
+ color: active ? "#93c5fd" : textSecondary,
627
873
  cursor: active ? "default" : "pointer",
628
- whiteSpace: "nowrap"
874
+ whiteSpace: "nowrap",
875
+ fontSize: 12
629
876
  }
630
877
  }, i18n.t(option.labelKey));
631
878
  }))), (0, react.createElement)("div", { style: {
@@ -633,14 +880,28 @@ function ProviderOverlay(props) {
633
880
  gap: 8
634
881
  } }, (0, react.createElement)("button", {
635
882
  type: "button",
883
+ title: i18n.t("panel.refresh"),
884
+ "aria-busy": state.status === "loading",
636
885
  disabled: !credentialReady || !target.sessionId || state.status === "loading" || state.status === "applying",
637
886
  onClick: () => {
638
887
  if (target.sessionId) consume(props.store.refresh(target.sessionId));
888
+ },
889
+ style: {
890
+ ...actionButtonStyle,
891
+ opacity: state.status === "loading" ? .6 : 1
892
+ }
893
+ }, (0, react.createElement)("span", {
894
+ "aria-hidden": true,
895
+ style: {
896
+ display: "inline-block",
897
+ marginRight: 6
639
898
  }
640
- }, i18n.t("panel.refresh")), (0, react.createElement)("button", {
899
+ }, "↻"), state.status === "loading" ? i18n.t("panel.refreshing") : i18n.t("panel.refresh")), (0, react.createElement)("button", {
641
900
  type: "button",
901
+ title: i18n.t("panel.close"),
642
902
  "aria-label": i18n.t("panel.close"),
643
- onClick: () => props.store.close()
903
+ onClick: () => props.store.close(),
904
+ style: iconButtonStyle
644
905
  }, "×"))), state.credential !== null && !state.credential.configured ? (0, react.createElement)("p", {
645
906
  role: "status",
646
907
  style: {
@@ -662,20 +923,50 @@ function ProviderOverlay(props) {
662
923
  color: "#fca5a5",
663
924
  background: "rgba(127,29,29,.35)"
664
925
  }
665
- }, state.error) : null, state.status === "loading" && rows.length === 0 ? (0, react.createElement)("p", { style: {
926
+ }, state.error) : null, state.successTag && state.previousTag ? (0, react.createElement)("div", {
927
+ role: "status",
928
+ style: {
929
+ display: "flex",
930
+ alignItems: "center",
931
+ justifyContent: "space-between",
932
+ gap: 12,
933
+ margin: "12px 22px 0",
934
+ padding: "9px 11px",
935
+ borderRadius: 8,
936
+ color: "#bbf7d0",
937
+ background: "rgba(20,83,45,.24)",
938
+ border: "1px solid rgba(34,197,94,.22)",
939
+ fontSize: 12
940
+ }
941
+ }, (0, react.createElement)("span", null, i18n.t("panel.switchSuccess")), (0, react.createElement)("button", {
942
+ type: "button",
943
+ disabled: state.status === "applying",
944
+ onClick: () => {
945
+ if (target.sessionId && state.previousTag) consume(props.store.apply(target.sessionId, state.previousTag));
946
+ },
947
+ style: {
948
+ ...actionButtonStyle,
949
+ height: 30,
950
+ color: "#bbf7d0",
951
+ whiteSpace: "nowrap"
952
+ }
953
+ }, i18n.t("panel.undo"))) : null, state.status === "loading" && rows.length === 0 ? (0, react.createElement)("p", { style: {
666
954
  padding: 22,
667
- color: "#a1a1aa"
955
+ color: textTertiary
668
956
  } }, i18n.t("panel.loading")) : null, credentialReady && rows.length > 0 ? (0, react.createElement)("div", { style: {
669
957
  display: "grid",
670
958
  gap: 2,
671
959
  margin: "12px 22px 0",
672
- color: "#71717a",
960
+ color: textTertiary,
673
961
  fontSize: 11,
674
962
  lineHeight: 1.5
675
963
  } }, (0, react.createElement)("span", null, i18n.t("panel.switchHint")), (0, react.createElement)("span", { style: {
676
- color: "#52525b",
677
- fontSize: 10
678
- } }, 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: {
679
970
  overflowY: "auto",
680
971
  padding: "12px 14px 20px"
681
972
  } }, ...rows.flatMap((row) => {
@@ -686,7 +977,7 @@ function ProviderOverlay(props) {
686
977
  key: `group-${row.group}`,
687
978
  style: {
688
979
  margin: "14px 8px 8px",
689
- color: "#a1a1aa",
980
+ color: textTertiary,
690
981
  fontSize: 12,
691
982
  textTransform: "uppercase",
692
983
  letterSpacing: ".06em"
@@ -699,13 +990,15 @@ function ProviderOverlay(props) {
699
990
  gridTemplateColumns: providerGridColumns,
700
991
  gap: 14,
701
992
  padding: "0 14px 8px",
702
- color: "#71717a",
993
+ color: textTertiary,
703
994
  fontSize: 11,
704
995
  letterSpacing: ".04em",
705
996
  textTransform: "uppercase"
706
997
  }
707
998
  }, (0, react.createElement)("span", null, columns[0]), (0, react.createElement)("span", null, columns[1]), (0, react.createElement)("span", { style: { textAlign: "right" } }, columns[2])));
708
999
  }
1000
+ const switching = state.applyingTag === row.tag;
1001
+ const switched = state.successTag === row.tag;
709
1002
  nodes.push((0, react.createElement)("button", {
710
1003
  key: row.tag,
711
1004
  type: "button",
@@ -713,6 +1006,7 @@ function ProviderOverlay(props) {
713
1006
  onClick: () => {
714
1007
  if (target.sessionId) consume(props.store.apply(target.sessionId, row.tag));
715
1008
  },
1009
+ title: row.current ? i18n.t("panel.current") : i18n.t("panel.switchAction"),
716
1010
  style: {
717
1011
  width: "100%",
718
1012
  display: "grid",
@@ -722,28 +1016,124 @@ function ProviderOverlay(props) {
722
1016
  padding: "12px 14px",
723
1017
  marginBottom: 6,
724
1018
  borderRadius: 10,
725
- border: row.current ? "1px solid #3b82f6" : "1px solid rgba(255,255,255,.08)",
726
- background: row.current ? "rgba(37,99,235,.14)" : "rgba(255,255,255,.035)",
1019
+ border: row.current ? "1px solid #3b82f6" : borderL2,
1020
+ background: row.current ? "rgba(37,99,235,.14)" : bgLayer3,
727
1021
  color: "inherit",
728
1022
  textAlign: "left",
729
- cursor: row.current ? "default" : "pointer"
1023
+ cursor: row.current ? "default" : "pointer",
1024
+ transition: "background .15s ease,border-color .15s ease,transform .15s ease",
1025
+ opacity: state.status === "applying" && !switching ? .55 : 1
730
1026
  }
731
- }, (0, react.createElement)("strong", null, row.providerName), (0, react.createElement)("span", {
1027
+ }, (0, react.createElement)("span", { style: {
1028
+ display: "flex",
1029
+ alignItems: "center",
1030
+ gap: 8,
1031
+ minWidth: 0
1032
+ } }, (0, react.createElement)("strong", { style: {
1033
+ overflow: "hidden",
1034
+ textOverflow: "ellipsis"
1035
+ } }, row.providerName), (0, react.createElement)("small", { style: {
1036
+ flex: "0 0 auto",
1037
+ padding: "2px 6px",
1038
+ borderRadius: 999,
1039
+ color: "#a5b4fc",
1040
+ background: "rgba(99,102,241,.13)",
1041
+ fontSize: 9,
1042
+ fontWeight: 600
1043
+ } }, row.badge)), (0, react.createElement)("span", { style: {
1044
+ display: "grid",
1045
+ gap: 3,
1046
+ minWidth: 0
1047
+ } }, (0, react.createElement)("span", {
732
1048
  title: row.detail,
733
1049
  style: {
734
- color: "#a1a1aa",
735
- fontSize: 13,
1050
+ color: textTertiary,
1051
+ fontSize: 12,
1052
+ overflow: "hidden",
1053
+ textOverflow: "ellipsis",
1054
+ whiteSpace: "nowrap"
1055
+ }
1056
+ }, row.detail), (0, react.createElement)("span", {
1057
+ title: row.priceLabel,
1058
+ style: {
1059
+ color: textSecondary,
1060
+ fontSize: 11,
736
1061
  overflow: "hidden",
737
1062
  textOverflow: "ellipsis",
738
1063
  whiteSpace: "nowrap"
739
1064
  }
740
- }, row.detail), (0, react.createElement)("span", { style: {
741
- color: row.current ? "#60a5fa" : "#d4d4d8",
742
- fontSize: 12,
743
- textAlign: "right"
744
- } }, row.scoreLabel)));
1065
+ }, row.priceLabel), row.savingsLabel ? (0, react.createElement)("small", { style: {
1066
+ color: "#86efac",
1067
+ fontSize: 10,
1068
+ fontWeight: 600
1069
+ } }, row.savingsLabel) : null), (0, react.createElement)("span", {
1070
+ title: row.breakdown,
1071
+ "aria-label": `${row.scoreLabel}; ${row.breakdown}`,
1072
+ style: {
1073
+ color: row.current ? "#60a5fa" : textSecondary,
1074
+ fontSize: 12,
1075
+ textAlign: "right",
1076
+ textDecoration: "underline dotted rgba(161,161,170,.6)",
1077
+ textUnderlineOffset: 3
1078
+ }
1079
+ }, switching ? i18n.t("panel.switching") : switched ? i18n.t("panel.switched") : row.scoreLabel)));
745
1080
  return nodes;
746
- }))));
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
+ })))));
747
1137
  }
748
1138
 
749
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
@@ -275,8 +275,10 @@ function round(value, digits = 2) {
275
275
  return Math.round((value + Number.EPSILON) * factor) / factor;
276
276
  }
277
277
  function uptimeMultiplier(uptime, config) {
278
- const safety = uptime === null ? .7 : uptime >= 99.5 ? 1 : uptime >= 99 ? .85 : uptime >= 97 ? .6 : uptime >= 90 ? .3 : .05;
279
- const configured = uptime !== null && uptime < config.uptimePenaltyThreshold ? config.uptimePenaltyFactor : 1;
278
+ if (uptime === null) return .85;
279
+ const interpolate = (value, low, high, lowScore, highScore) => lowScore + (highScore - lowScore) * (value - low) / (high - low);
280
+ const safety = uptime >= 99.5 ? 1 : uptime >= 99 ? interpolate(uptime, 99, 99.5, .97, 1) : uptime >= 98 ? interpolate(uptime, 98, 99, .9, .97) : uptime >= 95 ? interpolate(uptime, 95, 98, .75, .9) : uptime >= 90 ? interpolate(uptime, 90, 95, .5, .75) : interpolate(Math.max(0, uptime), 0, 90, .1, .5);
281
+ const configured = uptime >= config.uptimePenaltyThreshold ? 1 : interpolate(Math.max(config.uptimePenaltyThreshold - 5, uptime), config.uptimePenaltyThreshold - 5, config.uptimePenaltyThreshold, config.uptimePenaltyFactor, 1);
280
282
  return Math.min(safety, configured);
281
283
  }
282
284
  function rankEndpoints(endpoints, config, currentTag) {
@@ -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.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";
@@ -13,11 +13,26 @@ export interface ProviderPanelSnapshot {
13
13
  } | null;
14
14
  strategy: RankingStrategy;
15
15
  error: string | null;
16
+ applyingTag: string | null;
17
+ successTag: string | null;
18
+ updatedAt: number | null;
19
+ previousTag: string | null;
16
20
  credential: {
17
21
  configured: boolean;
18
22
  ref: string;
19
23
  source: 'provider' | 'fallback';
20
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;
21
36
  }
22
37
  export interface ConfiguredProviderModel {
23
38
  provider: string;
@@ -38,5 +53,6 @@ export interface ProviderPanelStore {
38
53
  apply(sessionId: string, tag: string): Promise<void>;
39
54
  dispose(): void;
40
55
  }
41
- 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;
42
58
  //# sourceMappingURL=provider-panel-store.d.ts.map
@@ -1,5 +1,6 @@
1
1
  import { type CSSProperties, type ReactNode } from 'react';
2
2
  import type { RecommendationResponse } from '../directory.js';
3
+ import type { RankedProvider } from '../types.js';
3
4
  import type { RankingStrategy } from '../config.js';
4
5
  import type { ProviderPanelStore } from './provider-panel-store.js';
5
6
  interface SessionListLike {
@@ -17,7 +18,23 @@ export interface ProviderRowView {
17
18
  detail: string;
18
19
  score: number;
19
20
  scoreLabel: string;
21
+ badge: string;
22
+ breakdown: string;
23
+ priceLabel: string;
24
+ savingsLabel: string | null;
20
25
  }
26
+ export declare function providerInsight(row: RankedProvider, i18n?: {
27
+ locale: import("./i18n.js").SupportedLocale;
28
+ t(key: import("./i18n.js").TranslationKey, params?: Record<string, string | number>): string;
29
+ }): {
30
+ badge: string;
31
+ breakdown: string;
32
+ };
33
+ export declare function providerPriceLabel(row: Pick<RankedProvider, 'price'>, i18n?: {
34
+ locale: import("./i18n.js").SupportedLocale;
35
+ t(key: import("./i18n.js").TranslationKey, params?: Record<string, string | number>): string;
36
+ }): string;
37
+ export declare function estimatedSavings(current: RankedProvider['price'], candidate: RankedProvider['price']): number | null;
21
38
  export declare const STRATEGY_OPTIONS: ReadonlyArray<{
22
39
  id: RankingStrategy;
23
40
  labelKey: 'panel.strategy.balanced' | 'panel.strategy.price' | 'panel.strategy.speed' | 'panel.strategy.context';
@@ -39,6 +56,10 @@ export declare function ProviderSidebarTrigger(props: GlobalSlotProps & {
39
56
  wide: boolean;
40
57
  store: ProviderPanelStore;
41
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;
42
63
  export declare const providerGridColumns = "minmax(110px,1fr) minmax(0,2fr) minmax(54px,auto)";
43
64
  export declare function ProviderOverlay(props: GlobalSlotProps & {
44
65
  store: ProviderPanelStore;
package/package.json CHANGED
@@ -1,23 +1,45 @@
1
1
  {
2
2
  "name": "dsh-openrouter-provider-advisor",
3
- "version": "0.1.0",
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
- "keywords": ["deepseek-harness", "dsh", "dsh-plugin", "openrouter", "provider-routing", "typescript"],
6
- "repository": { "type": "git", "url": "git+https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor.git" },
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "openrouter",
10
+ "provider-routing",
11
+ "typescript"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor.git"
16
+ },
7
17
  "homepage": "https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor#readme",
8
- "bugs": { "url": "https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/issues" },
9
- "publishConfig": { "access": "public" },
18
+ "bugs": {
19
+ "url": "https://github.com/xuanfengtechx/dsh-openrouter-provider-advisor/issues"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
10
24
  "type": "module",
11
25
  "main": "lib/index.js",
12
26
  "types": "lib/types/index.d.ts",
13
27
  "exports": {
14
- ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
15
- "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" },
28
+ ".": {
29
+ "types": "./lib/types/index.d.ts",
30
+ "default": "./lib/index.js"
31
+ },
32
+ "./client": {
33
+ "types": "./lib/types/client/index.d.ts",
34
+ "default": "./lib/client.js"
35
+ },
16
36
  "./cordis.patch.yml": "./cordis.patch.yml",
17
37
  "./package.json": "./package.json"
18
38
  },
19
39
  "dsh": {
20
- "bundle": { "patch": "./cordis.patch.yml" },
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ },
21
43
  "client": {
22
44
  "platform": "web",
23
45
  "inject": [
@@ -71,7 +93,19 @@
71
93
  "typescript": "^5.9.2",
72
94
  "vitest": "^4.1.8"
73
95
  },
74
- "engines": { "node": "^22.19.0 || >=24.0.0" },
96
+ "engines": {
97
+ "node": "^22.19.0 || >=24.0.0"
98
+ },
75
99
  "license": "MIT",
76
- "files": ["lib/**/*.js", "lib/types/**/*.d.ts", "cordis.patch.yml", "dsh.plugin.json", "README.md", "README_EN.md", "docs/images/*.png", "LICENSE"]
100
+ "files": [
101
+ "lib/**/*.js",
102
+ "lib/types/**/*.d.ts",
103
+ "cordis.patch.yml",
104
+ "dsh.plugin.json",
105
+ "README.md",
106
+ "README_EN.md",
107
+ "CHANGELOG.md",
108
+ "docs/images/*.png",
109
+ "LICENSE"
110
+ ]
77
111
  }