@goodandready/dsh-opencode-provider 0.5.1 → 0.5.3

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/client.js CHANGED
@@ -38,6 +38,10 @@ window.__ModuleLoader__.load({
38
38
  accountRef: "Credential ref",
39
39
  accountSource: "Source",
40
40
  addAccount: "Add account",
41
+ importAllAuth: "Import all from auth.json",
42
+ checkAllAccounts: "Check all accounts",
43
+ importedAll: "Imported all auth.json keys; review the accounts and save.",
44
+ accountsImported: "Imported keys",
41
45
  removeAccount: "Remove",
42
46
  pinAccount: "Pin",
43
47
  activeAccount: "Pinned account",
@@ -63,6 +67,8 @@ window.__ModuleLoader__.load({
63
67
  fiveHour: "5-hour",
64
68
  weekly: "Weekly",
65
69
  monthly: "Monthly",
70
+ runway: "Runway (est.)",
71
+ runwaySafe: "safe",
66
72
  noUsage: "Quota is unavailable.",
67
73
  usageError: "Quota error",
68
74
  refresh: "Refresh",
@@ -107,6 +113,8 @@ window.__ModuleLoader__.load({
107
113
  heatmapByHour: "By hour (all days)",
108
114
  heatmapAllModels: "All models",
109
115
  heatmapModel: "Model",
116
+ heatmapByAccount: "Account",
117
+ heatmapAllAccounts: "All accounts",
110
118
  analyticsHint: "Aggregated from local session metadata by last activity. A session that switched models is counted under its last model; failures are not exposed by the projection.",
111
119
  analyticsEmpty: "No OpenCode sessions in this window.",
112
120
  showAll: "Show all",
@@ -144,6 +152,21 @@ window.__ModuleLoader__.load({
144
152
  maxOutputTokens: "Output limit (empty = model default, 0 = omit)",
145
153
  contextWindow: "Context override",
146
154
  showSidebarQuota: "Show the quota card in the sidebar",
155
+ routing: "Routing",
156
+ routingHint: "Recent account/model choices. No prompts or keys are recorded.",
157
+ routingEmpty: "No routing decisions yet.",
158
+ routingRule: "Model rule",
159
+ routingSticky: "Sticky session",
160
+ routingPinned: "Pinned account",
161
+ routingDefault: "Automatic",
162
+ routingRotation: "Rotation",
163
+ routingFallback: "Quota fallback",
164
+ routingFailed: "Failed",
165
+ quotaFallbackModel: "Quota fallback model",
166
+ quotaFallbackModelHint: "If Go rejects with a quota error, retry once on this free model through Console/Zen.",
167
+ stickySession: "Sticky account per conversation",
168
+ stickySessionHint: "Prefer the last successful account for the same conversation; explicit model rules and pinned accounts win.",
169
+ off: "Off",
147
170
  pluginUpdate: "Plugin update",
148
171
  currentVersion: "Current version",
149
172
  latestVersion: "Latest version",
@@ -195,6 +218,10 @@ window.__ModuleLoader__.load({
195
218
  accountRef: "凭据引用",
196
219
  accountSource: "来源",
197
220
  addAccount: "添加账户",
221
+ importAllAuth: "从 auth.json 导入全部",
222
+ checkAllAccounts: "检查全部账户",
223
+ importedAll: "已导入 auth.json 全部密钥;请检查账户并保存。",
224
+ accountsImported: "已导入密钥",
198
225
  removeAccount: "移除",
199
226
  pinAccount: "固定",
200
227
  activeAccount: "固定账户",
@@ -220,6 +247,8 @@ window.__ModuleLoader__.load({
220
247
  fiveHour: "5 小时",
221
248
  weekly: "每周",
222
249
  monthly: "每月",
250
+ runway: "预计可用时长",
251
+ runwaySafe: "安全",
223
252
  noUsage: "配额不可用。",
224
253
  usageError: "配额错误",
225
254
  refresh: "刷新",
@@ -264,6 +293,8 @@ window.__ModuleLoader__.load({
264
293
  heatmapByHour: "按小时(全部日期)",
265
294
  heatmapAllModels: "全部模型",
266
295
  heatmapModel: "模型",
296
+ heatmapByAccount: "账户",
297
+ heatmapAllAccounts: "全部账户",
267
298
  analyticsHint: "按最后活动时间聚合本地会话元数据。切换过模型的会话计入最后使用的模型;本地投影不暴露失败。",
268
299
  analyticsEmpty: "此窗口内没有 OpenCode 会话。",
269
300
  showAll: "显示全部",
@@ -301,6 +332,21 @@ window.__ModuleLoader__.load({
301
332
  maxOutputTokens: "输出上限(空 = 模型默认,0 = 省略)",
302
333
  contextWindow: "上下文覆盖",
303
334
  showSidebarQuota: "在侧边栏显示配额卡片",
335
+ routing: "路由",
336
+ routingHint: "最近使用的账户/模型选择。不会记录提示词或密钥。",
337
+ routingEmpty: "尚无路由决策。",
338
+ routingRule: "模型规则",
339
+ routingSticky: "会话粘性",
340
+ routingPinned: "固定账户",
341
+ routingDefault: "自动",
342
+ routingRotation: "轮换",
343
+ routingFallback: "配额回退",
344
+ routingFailed: "失败",
345
+ quotaFallbackModel: "配额回退模型",
346
+ quotaFallbackModelHint: "Go 返回配额错误时,通过 Console/Zen 用此免费模型重试一次。",
347
+ stickySession: "每个会话固定账户",
348
+ stickySessionHint: "同一会话优先使用上次成功的账户;显式模型规则和固定账户优先。",
349
+ off: "关闭",
304
350
  pluginUpdate: "插件更新",
305
351
  currentVersion: "当前版本",
306
352
  latestVersion: "最新版本",
@@ -386,10 +432,13 @@ window.__ModuleLoader__.load({
386
432
  body: JSON.stringify({ ref })
387
433
  });
388
434
  }
389
- function importAuth(entry) {
435
+ function importAuth(entry, ref) {
390
436
  return call("/dsh-opencode/auth-import", {
391
437
  method: "POST",
392
- body: JSON.stringify({ entry })
438
+ body: JSON.stringify(ref === void 0 ? { entry } : {
439
+ entry,
440
+ ref
441
+ })
393
442
  });
394
443
  }
395
444
  function fetchAnalytics(days = 30) {
@@ -429,10 +478,12 @@ window.__ModuleLoader__.load({
429
478
  function RequestHeatmapView(props) {
430
479
  const { heatmap, t } = props;
431
480
  const [selected, setSelected] = (0, react.useState)("all");
481
+ const [selectedAccount, setSelectedAccount] = (0, react.useState)("all");
432
482
  if (heatmap === void 0) return null;
433
483
  const models = heatmap.models ?? [];
434
- const selectedModel = models.find((entry) => `${entry.provider}/${entry.model}` === selected);
435
- const view = selectedModel ?? heatmap;
484
+ const accounts = heatmap.accounts ?? [];
485
+ const selectedModel = selectedAccount === "all" ? models.find((entry) => `${entry.provider}/${entry.model}` === selected) : void 0;
486
+ const view = accounts.find((entry) => entry.account === selectedAccount) ?? selectedModel ?? heatmap;
436
487
  const selectedKey = selectedModel === void 0 ? "all" : selected;
437
488
  const max = Math.max(0, ...view.grid.flat());
438
489
  const maxHours = Math.max(0, ...view.hours);
@@ -452,10 +503,37 @@ window.__ModuleLoader__.load({
452
503
  t("requests")
453
504
  ]
454
505
  }),
506
+ accounts.length > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
507
+ className: "oc-select",
508
+ value: selectedAccount,
509
+ title: t("heatmapByAccount"),
510
+ onChange: (event) => {
511
+ setSelectedAccount(event.target.value);
512
+ setSelected("all");
513
+ },
514
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
515
+ value: "all",
516
+ children: [
517
+ t("heatmapAllAccounts"),
518
+ " (",
519
+ heatmap.total,
520
+ ")"
521
+ ]
522
+ }), accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
523
+ value: entry.account,
524
+ children: [
525
+ entry.account,
526
+ " (",
527
+ entry.total,
528
+ ")"
529
+ ]
530
+ }, entry.account))]
531
+ }) : null,
455
532
  models.length > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
456
533
  className: "oc-select",
457
534
  value: selectedKey,
458
535
  title: t("heatmapModel"),
536
+ disabled: selectedAccount !== "all",
459
537
  onChange: (event) => setSelected(event.target.value),
460
538
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
461
539
  value: "all",
@@ -841,6 +919,73 @@ window.__ModuleLoader__.load({
841
919
  });
842
920
  }
843
921
  //#endregion
922
+ //#region src/quota-runway.ts
923
+ const HOUR = 36e5;
924
+ const DAY = 24 * HOUR;
925
+ const DURATIONS = {
926
+ fiveHour: 5 * HOUR,
927
+ weekly: 7 * DAY,
928
+ monthly: 30 * DAY
929
+ };
930
+ function percentOf(window) {
931
+ const value = Number(window.percentUsed);
932
+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
933
+ }
934
+ /**
935
+ * Estimate when one window reaches 100% at the average burn rate observed
936
+ * since it started. The API exposes only the current percentage and reset
937
+ * time; the start is derived from the known window length, so this is a
938
+ * deliberately conservative, approximate runway.
939
+ */
940
+ function estimateRunway(window, durationMs, now = Date.now()) {
941
+ if (window === void 0 || window.resetsAt === void 0) return void 0;
942
+ const resetsAtMs = Date.parse(window.resetsAt);
943
+ if (!Number.isFinite(resetsAtMs) || resetsAtMs <= now) return void 0;
944
+ const used = percentOf(window);
945
+ const timeToReset = resetsAtMs - now;
946
+ if (used >= 100) return {
947
+ etaMs: 0,
948
+ resetsAtMs,
949
+ exhaustsBeforeReset: true
950
+ };
951
+ const elapsed = now - (resetsAtMs - durationMs);
952
+ if (elapsed <= 0) return void 0;
953
+ const rate = used / elapsed;
954
+ if (!Number.isFinite(rate) || rate <= 0) return {
955
+ etaMs: Number.POSITIVE_INFINITY,
956
+ resetsAtMs,
957
+ exhaustsBeforeReset: false
958
+ };
959
+ const etaMs = (100 - used) / rate;
960
+ return {
961
+ etaMs,
962
+ resetsAtMs,
963
+ exhaustsBeforeReset: etaMs < timeToReset
964
+ };
965
+ }
966
+ /** Pick the window that is projected to run out first, if any. */
967
+ function accountRunway(windows, now = Date.now()) {
968
+ if (windows === void 0) return { kind: "unknown" };
969
+ let best;
970
+ let known = 0;
971
+ for (const name of Object.keys(DURATIONS)) {
972
+ const estimate = estimateRunway(windows[name], DURATIONS[name], now);
973
+ if (estimate === void 0) continue;
974
+ known += 1;
975
+ if (!estimate.exhaustsBeforeReset) continue;
976
+ if (best === void 0 || estimate.etaMs < best.etaMs) best = {
977
+ etaMs: estimate.etaMs,
978
+ window: name
979
+ };
980
+ }
981
+ if (best !== void 0) return {
982
+ kind: "eta",
983
+ etaMs: best.etaMs,
984
+ window: best.window
985
+ };
986
+ return known === 0 ? { kind: "unknown" } : { kind: "safe" };
987
+ }
988
+ //#endregion
844
989
  //#region src/client/quota-card.tsx
845
990
  const PANEL_EVENT = "dsh-opencode-panel-state";
846
991
  function broadcastPanel(open) {
@@ -895,6 +1040,18 @@ window.__ModuleLoader__.load({
895
1040
  function usagePercent(window) {
896
1041
  return window === void 0 ? "—" : `${Math.round(window.percentUsed)}%`;
897
1042
  }
1043
+ function runwayText(summary) {
1044
+ if (summary.kind === "safe") return "safe";
1045
+ if (summary.kind === "unknown") return "—";
1046
+ if (summary.etaMs <= 0) return "now";
1047
+ const hours = summary.etaMs / 36e5;
1048
+ if (hours >= 24) return `~${Math.round(hours / 24)}d`;
1049
+ if (hours >= 1) return `~${Math.round(hours)}h`;
1050
+ return `~${Math.max(1, Math.round(hours * 60))}m`;
1051
+ }
1052
+ function runwayTitle(summary, t) {
1053
+ return summary.kind === "eta" ? `${t("runway")}: ${summary.window}` : "";
1054
+ }
898
1055
  function accountStatus(account, t) {
899
1056
  if (account.mark !== "") {
900
1057
  const label = account.mark === "invalid-credential" ? t("markInvalid") : account.mark === "quota" ? t("markQuota") : account.mark === "rate-limit" ? t("markRateLimit") : t("markTransport");
@@ -907,6 +1064,14 @@ window.__ModuleLoader__.load({
907
1064
  windows?.monthly
908
1065
  ].map((window) => window?.status).find((value) => value !== void 0 && value !== "ok");
909
1066
  }
1067
+ function routingLabel(reason, t) {
1068
+ if (reason === "rule") return t("routingRule");
1069
+ if (reason === "sticky") return t("routingSticky");
1070
+ if (reason === "pinned") return t("routingPinned");
1071
+ if (reason === "rotation") return t("routingRotation");
1072
+ if (reason === "quota-fallback") return t("routingFallback");
1073
+ return t("routingDefault");
1074
+ }
910
1075
  function fillClass(value) {
911
1076
  if (value >= 90) return "oc-footFill oc-footFillBad";
912
1077
  if (value >= 75) return "oc-footFill oc-footFillWarn";
@@ -1195,9 +1360,11 @@ window.__ModuleLoader__.load({
1195
1360
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("accountLabel") }),
1196
1361
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("fiveHour") }),
1197
1362
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("weekly") }),
1198
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("monthly") })
1363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("monthly") }),
1364
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("runway") })
1199
1365
  ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: state.accounts.map((account) => {
1200
1366
  const accountWindows = account.usage?.report?.windows;
1367
+ const runway = accountRunway(accountWindows);
1201
1368
  const status = accountStatus(account, t);
1202
1369
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1203
1370
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", { children: [
@@ -1212,7 +1379,11 @@ window.__ModuleLoader__.load({
1212
1379
  ] }),
1213
1380
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: usagePercent(accountWindows?.fiveHour) }),
1214
1381
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: usagePercent(accountWindows?.weekly) }),
1215
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: usagePercent(accountWindows?.monthly) })
1382
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: usagePercent(accountWindows?.monthly) }),
1383
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1384
+ title: runwayTitle(runway, t),
1385
+ children: runway.kind === "safe" ? t("runwaySafe") : runwayText(runway)
1386
+ })
1216
1387
  ] }, account.label);
1217
1388
  }) })]
1218
1389
  }),
@@ -1222,6 +1393,43 @@ window.__ModuleLoader__.load({
1222
1393
  })
1223
1394
  ]
1224
1395
  }),
1396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1397
+ className: "oc-card",
1398
+ children: [
1399
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1400
+ className: "oc-card-title",
1401
+ children: t("routing")
1402
+ }),
1403
+ (state?.routing ?? []).length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1404
+ className: "oc-hint",
1405
+ children: t("routingEmpty")
1406
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1407
+ className: "oc-table-scroll",
1408
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
1409
+ className: "oc-table",
1410
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1411
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("date") }),
1412
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("accountLabel") }),
1413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("model") }),
1414
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("routing") })
1415
+ ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: (state?.routing ?? []).slice(0, 8).map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: new Date(row.at).toLocaleTimeString() }),
1417
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.account }),
1418
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.model }),
1419
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", { children: [
1420
+ routingLabel(row.reason, t),
1421
+ row.fallback ? ` · ${t("routingFallback")}` : "",
1422
+ row.ok ? "" : ` · ${t("routingFailed")}${row.error === void 0 ? "" : ` (${row.error})`}`
1423
+ ] })
1424
+ ] }, `${row.at}-${row.account}-${row.model}-${row.reason}`)) })]
1425
+ })
1426
+ }),
1427
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1428
+ className: "oc-hint",
1429
+ children: t("routingHint")
1430
+ })
1431
+ ]
1432
+ }),
1225
1433
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1226
1434
  className: "oc-card",
1227
1435
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1329,6 +1537,8 @@ window.__ModuleLoader__.load({
1329
1537
  activeAccount: state.config.activeAccount,
1330
1538
  accounts: state.config.accounts.map((account) => ({ ...account })),
1331
1539
  modelAccountRules: state.config.modelAccountRules.map((rule) => ({ ...rule })),
1540
+ quotaFallbackModel: state.config.quotaFallbackModel,
1541
+ stickySession: state.config.stickySession,
1332
1542
  visibleModels: state.config.visibleModels.length === 0 ? allIds : [...state.config.visibleModels],
1333
1543
  filterModelsByPlan: state.config.filterModelsByPlan,
1334
1544
  showAll: state.config.visibleModels.length === 0,
@@ -1364,6 +1574,8 @@ window.__ModuleLoader__.load({
1364
1574
  pattern: rule.pattern.trim(),
1365
1575
  account: rule.account.trim()
1366
1576
  })).filter((rule) => rule.pattern !== "" && rule.account !== ""),
1577
+ quotaFallbackModel: draft.quotaFallbackModel.trim(),
1578
+ stickySession: draft.stickySession,
1367
1579
  visibleModels: draft.showAll ? [] : draft.visibleModels,
1368
1580
  filterModelsByPlan: draft.filterModelsByPlan,
1369
1581
  modelTransports: Object.fromEntries(Object.entries(draft.modelTransports).filter(([, value]) => value !== ""))
@@ -1560,6 +1772,7 @@ window.__ModuleLoader__.load({
1560
1772
  const [testModel, setTestModel] = (0, react.useState)("deepseek-v4.1-flash");
1561
1773
  const [testing, setTesting] = (0, react.useState)(false);
1562
1774
  const [testResult, setTestResult] = (0, react.useState)(null);
1775
+ const [importing, setImporting] = (0, react.useState)(false);
1563
1776
  const load = (0, react.useCallback)(async () => {
1564
1777
  setLoading(true);
1565
1778
  try {
@@ -1610,6 +1823,29 @@ window.__ModuleLoader__.load({
1610
1823
  setError(caught instanceof Error ? caught.message : t("failed"));
1611
1824
  }
1612
1825
  };
1826
+ const importAllAuth = async () => {
1827
+ if (state === null || draft === null) return;
1828
+ const refFor = (entry) => `OPENCODE_AUTH_${entry.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
1829
+ const missing = state.auth.entries.filter((entry) => !draft.accounts.some((account) => account.label === entry || account.apiKeyEnv === refFor(entry)));
1830
+ if (missing.length === 0) {
1831
+ setNotice(t("importedAll"));
1832
+ return;
1833
+ }
1834
+ setImporting(true);
1835
+ setError(null);
1836
+ try {
1837
+ for (const entry of missing) await importAuth(entry, refFor(entry));
1838
+ update({ accounts: [...draft.accounts, ...missing.map((entry) => ({
1839
+ label: entry,
1840
+ apiKeyEnv: refFor(entry)
1841
+ }))] });
1842
+ setNotice(t("importedAll"));
1843
+ } catch (caught) {
1844
+ setError(caught instanceof Error ? caught.message : t("failed"));
1845
+ } finally {
1846
+ setImporting(false);
1847
+ }
1848
+ };
1613
1849
  if (loading && state === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1614
1850
  className: "oc-hint",
1615
1851
  children: t("loading")
@@ -2126,6 +2362,23 @@ window.__ModuleLoader__.load({
2126
2362
  }] }),
2127
2363
  children: t("addAccount")
2128
2364
  }),
2365
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2366
+ type: "button",
2367
+ className: "oc-button",
2368
+ disabled: importing,
2369
+ onClick: () => {
2370
+ importAllAuth();
2371
+ },
2372
+ children: importing ? t("loading") : t("importAllAuth")
2373
+ }),
2374
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2375
+ type: "button",
2376
+ className: "oc-button",
2377
+ onClick: () => {
2378
+ refresh();
2379
+ },
2380
+ children: t("checkAllAccounts")
2381
+ }),
2129
2382
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2130
2383
  className: "oc-badge",
2131
2384
  children: [
@@ -2206,6 +2459,61 @@ window.__ModuleLoader__.load({
2206
2459
  })
2207
2460
  ]
2208
2461
  }),
2462
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2463
+ className: "oc-card",
2464
+ children: [
2465
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
2466
+ className: "oc-card-title",
2467
+ children: t("routing")
2468
+ }),
2469
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2470
+ className: "oc-hint",
2471
+ children: t("routingHint")
2472
+ }),
2473
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2474
+ className: "oc-row",
2475
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2476
+ type: "checkbox",
2477
+ checked: draft.stickySession,
2478
+ onChange: (event) => update({ stickySession: event.target.checked })
2479
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("stickySession") })]
2480
+ }),
2481
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2482
+ className: "oc-hint",
2483
+ children: t("stickySessionHint")
2484
+ }),
2485
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2486
+ className: "oc-field",
2487
+ children: [
2488
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2489
+ className: "oc-label",
2490
+ children: t("quotaFallbackModel")
2491
+ }),
2492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2493
+ className: "oc-select",
2494
+ value: draft.quotaFallbackModel,
2495
+ onChange: (event) => update({ quotaFallbackModel: event.target.value }),
2496
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2497
+ value: "",
2498
+ children: t("off")
2499
+ }), state.models.filter((model) => model.free).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
2500
+ value: model.id,
2501
+ children: [
2502
+ model.name,
2503
+ " (",
2504
+ model.id,
2505
+ ")"
2506
+ ]
2507
+ }, model.id))]
2508
+ }),
2509
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2510
+ className: "oc-hint",
2511
+ children: t("quotaFallbackModelHint")
2512
+ })
2513
+ ]
2514
+ })
2515
+ ]
2516
+ }),
2209
2517
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2210
2518
  className: "oc-card",
2211
2519
  children: [
package/lib/index.d.ts CHANGED
@@ -55,6 +55,10 @@ interface Config {
55
55
  accounts?: AccountConfig[];
56
56
  /** Preferred account per model glob; other accounts remain fallbacks. */
57
57
  modelAccountRules?: ModelAccountRule[];
58
+ /** Free model used once when the Go quota rejects a request; empty disables it. */
59
+ quotaFallbackModel?: string;
60
+ /** Prefer the last successful account for a conversation. */
61
+ stickySession?: boolean;
58
62
  /** Pinned account id; empty means automatic. */
59
63
  activeAccount?: string;
60
64
  /** Show the quota card in the sidebar. */
@@ -82,6 +86,8 @@ interface ResolvedConfig {
82
86
  maxOutputTokens?: number;
83
87
  accounts: readonly AccountConfig[];
84
88
  modelAccountRules: readonly ModelAccountRule[];
89
+ quotaFallbackModel: string;
90
+ stickySession: boolean;
85
91
  activeAccount: string;
86
92
  showSidebarQuota: boolean;
87
93
  userAgent: string;
@@ -111,6 +117,17 @@ declare function createKeyPool(ctx: Context, config: () => ResolvedConfig): KeyP
111
117
  declare function createKeyResolver(ctx: Context, config: () => ResolvedConfig): KeyResolver;
112
118
  //#endregion
113
119
  //#region src/adapter.d.ts
120
+ type RoutingReason = 'rule' | 'sticky' | 'pinned' | 'default' | 'rotation' | 'quota-fallback';
121
+ interface RoutingTraceEntry {
122
+ readonly at: number;
123
+ readonly provider: string;
124
+ readonly model: string;
125
+ readonly account: string;
126
+ readonly reason: RoutingReason;
127
+ readonly fallback: boolean;
128
+ readonly ok: boolean;
129
+ readonly error?: string;
130
+ }
114
131
  interface OpenCodeAdapterDeps {
115
132
  readonly config: () => ResolvedConfig;
116
133
  readonly resolveKey: KeyResolver;
@@ -118,7 +135,7 @@ interface OpenCodeAdapterDeps {
118
135
  readonly resolveAttachments?: () => AttachmentStore | undefined;
119
136
  readonly fetchImpl?: typeof fetch;
120
137
  /** Test seam; defaults to the local append-only request log. */
121
- readonly recordRequest?: (provider: string, model: string, purpose?: string) => void;
138
+ readonly recordRequest?: (provider: string, model: string, purpose?: string, account?: string, reason?: string) => void;
122
139
  /** Test seam; defaults to ~/.dsh/storages/dsh-opencode/cooldowns.json. */
123
140
  readonly cooldownPath?: string;
124
141
  /** Receives a one-time warning when the provider tool limit drops tools. */
@@ -131,6 +148,8 @@ declare class OpenCodeAdapter extends LlmAdapter {
131
148
  private cooldownWrites;
132
149
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
133
150
  private readonly cooldowns;
151
+ private readonly sticky;
152
+ private readonly routing;
134
153
  private warnedTools;
135
154
  constructor(deps: OpenCodeAdapterDeps);
136
155
  providerInfo(provider: string): LlmProviderInfo;
@@ -138,9 +157,13 @@ declare class OpenCodeAdapter extends LlmAdapter {
138
157
  listModels(provider: string, opts?: {
139
158
  unfiltered?: boolean;
140
159
  }): Promise<readonly LlmModelInfo[]>;
160
+ /** Recent routing decisions, newest first; no keys or prompt content. */
161
+ routingTrace(): readonly RoutingTraceEntry[];
141
162
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
142
163
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
143
164
  private run;
165
+ private rememberSticky;
166
+ private pushTrace;
144
167
  private parse;
145
168
  private send;
146
169
  private keyId;
@@ -292,6 +315,12 @@ interface ModelRequestHeatmap {
292
315
  hours: number[];
293
316
  grid: number[][];
294
317
  }
318
+ interface AccountRequestHeatmap {
319
+ account: string;
320
+ total: number;
321
+ hours: number[];
322
+ grid: number[][];
323
+ }
295
324
  interface RequestHeatmap {
296
325
  since: number;
297
326
  total: number;
@@ -302,6 +331,7 @@ interface RequestHeatmap {
302
331
  firstAt: number | null;
303
332
  lastAt: number | null;
304
333
  models: ModelRequestHeatmap[];
334
+ accounts: AccountRequestHeatmap[];
305
335
  }
306
336
  //#endregion
307
337
  //#region src/routes.d.ts