@goodandready/dsh-opencode-provider 0.5.17 → 0.5.19

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
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.19
4
+
5
+ - **Per-model soft cooldown**: quota/free-tier/rate-limit rejections now cool only that key+model (`quota` 5m, `rate-limit` 60s); other models on the same account remain usable. Invalid credentials still cool the account. If every key is cooling, the request degrades to the normal candidate list instead of failing before dispatch.
6
+ - **Service status**: `/state` exposes the usage check time, latency and error, plus catalog source (`live`/`snapshot`), count and fetch time. Settings shows a compact Service status card.
7
+ - 105 tests; engine matrix/install matrix remain green.
8
+
9
+ ## 0.5.18
10
+
11
+ - **Compatibility range**: `engines.dsh`, DSH peer ranges and `dsh.compatibility.dsh` now use `>=0.2.1-rc.2 <0.2.2-0`; `dshReleases` keeps the tested rc.2/rc.3/rc.4 records. Future `0.2.1` RCs are admitted automatically; the next minor remains excluded.
12
+ - **Engine matrix**: added `test:engine` and `test:install`, which install the packed plugin into isolated web profiles for every `dshReleases` version, then boot the host and assert provider activation and a non-empty live catalog.
13
+ - **Key status**: `/state` now reports `keyStatus` per account (`ok`, `expired`, `invalid`, `missing`, `unavailable`, `unchecked`); usage 401/403 bodies are classified as expired/invalid. Settings shows the badge and adds a per-account **Check key** action backed by the new `/dsh-opencode/accounts/check` route. A check only reads the usage endpoint and does not send a model request.
14
+ - 104 tests.
15
+
3
16
  ## 0.5.17
4
17
 
5
18
  - **DSH 0.2.1-rc.4 compatibility**: added rc.4 to `engines.dsh`, peer ranges and `dsh.compatibility.dshReleases`; rc.2 and rc.3 remain supported. Development/typecheck dependencies moved to `@deepseek-ai/*@0.2.1-rc.4`.
package/README.md CHANGED
@@ -130,12 +130,12 @@ npm pack
130
130
  Install the **packed tarball** into a profile:
131
131
 
132
132
  ```bash
133
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.17.tgz
133
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
134
134
  # DSH Desktop:
135
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.17.tgz
135
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
136
136
 
137
137
  # Or install the published package:
138
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.17
138
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.19
139
139
  ```
140
140
 
141
141
  Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
package/README.ru.md CHANGED
@@ -129,12 +129,12 @@ npm pack
129
129
  Установка из **упакованного tarball**:
130
130
 
131
131
  ```bash
132
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.17.tgz
132
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
133
133
  # DSH Desktop:
134
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.17.tgz
134
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
135
135
 
136
136
  # Либо установка опубликованного пакета из npm:
137
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.17
137
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.19
138
138
  ```
139
139
 
140
140
  После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
package/README.zh.md CHANGED
@@ -124,12 +124,12 @@ npm pack
124
124
  通过**打包文件 (.tgz)**安装:
125
125
 
126
126
  ```bash
127
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.17.tgz
127
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
128
128
  # DSH 桌面端:
129
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.17.tgz
129
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
130
130
 
131
131
  # 或从 npm 安装已发布版本:
132
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.17
132
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.19
133
133
  ```
134
134
 
135
135
  安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
package/lib/client.js CHANGED
@@ -17,6 +17,25 @@ window.__ModuleLoader__.load({
17
17
  keyHint: "Paste a key from the OpenCode console, or import the key OpenCode already stored.",
18
18
  keyConfigured: "Configured",
19
19
  keyMissing: "Not configured",
20
+ keyStatusOk: "Key OK",
21
+ keyStatusExpired: "Key expired",
22
+ keyStatusInvalid: "Key rejected",
23
+ keyStatusMissing: "Key missing",
24
+ keyStatusUnavailable: "Key check failed",
25
+ keyStatusUnchecked: "Key unchecked",
26
+ checkKey: "Check key",
27
+ keyStatusHint: "A key check reads the usage endpoint; it does not send a model request.",
28
+ serviceStatus: "Service status",
29
+ serviceOk: "Reachable",
30
+ serviceDegraded: "Degraded",
31
+ usageEndpoint: "Usage endpoint",
32
+ catalogStatus: "Model catalog",
33
+ catalogLive: "live",
34
+ catalogSnapshot: "snapshot",
35
+ catalogAge: "updated {age}",
36
+ checkedAt: "checked {time}",
37
+ latencyValue: "{ms} ms",
38
+ notChecked: "not checked",
20
39
  keySource: "Source",
21
40
  keyWritable: "Writable",
22
41
  pasteKey: "Paste a key",
@@ -202,6 +221,25 @@ window.__ModuleLoader__.load({
202
221
  keyHint: "粘贴 OpenCode 控制台的密钥,或导入 OpenCode 已保存的密钥。",
203
222
  keyConfigured: "已配置",
204
223
  keyMissing: "未配置",
224
+ keyStatusOk: "密钥正常",
225
+ keyStatusExpired: "密钥已过期",
226
+ keyStatusInvalid: "密钥被拒绝",
227
+ keyStatusMissing: "缺少密钥",
228
+ keyStatusUnavailable: "检查失败",
229
+ keyStatusUnchecked: "未检查",
230
+ checkKey: "检查密钥",
231
+ keyStatusHint: "检查仅读取用量端点,不会发送模型请求。",
232
+ serviceStatus: "服务状态",
233
+ serviceOk: "可访问",
234
+ serviceDegraded: "异常",
235
+ usageEndpoint: "用量端点",
236
+ catalogStatus: "模型目录",
237
+ catalogLive: "实时",
238
+ catalogSnapshot: "快照",
239
+ catalogAge: "更新于 {age}",
240
+ checkedAt: "检查于 {time}",
241
+ latencyValue: "{ms} ms",
242
+ notChecked: "未检查",
205
243
  keySource: "来源",
206
244
  keyWritable: "可写",
207
245
  pasteKey: "粘贴密钥",
@@ -412,6 +450,12 @@ window.__ModuleLoader__.load({
412
450
  body: "{}"
413
451
  });
414
452
  }
453
+ function checkAccount(label) {
454
+ return call("/dsh-opencode/accounts/check", {
455
+ method: "POST",
456
+ body: JSON.stringify({ label })
457
+ });
458
+ }
415
459
  function saveConfig(patch) {
416
460
  return call("/dsh-opencode/config", {
417
461
  method: "POST",
@@ -1819,6 +1863,26 @@ window.__ModuleLoader__.load({
1819
1863
  }
1820
1864
  //#endregion
1821
1865
  //#region src/client/settings-page.tsx
1866
+ function keyStatusLabel(status, t) {
1867
+ if (status === "ok") return t("keyStatusOk");
1868
+ if (status === "expired") return t("keyStatusExpired");
1869
+ if (status === "invalid") return t("keyStatusInvalid");
1870
+ if (status === "missing") return t("keyStatusMissing");
1871
+ if (status === "unavailable") return t("keyStatusUnavailable");
1872
+ return t("keyStatusUnchecked");
1873
+ }
1874
+ function keyStatusClass(status) {
1875
+ if (status === "ok") return "oc-badge-ok";
1876
+ if (status === "expired" || status === "invalid") return "oc-badge-bad";
1877
+ return "oc-badge-warn";
1878
+ }
1879
+ function ageText(at) {
1880
+ if (at === void 0) return "";
1881
+ const minutes = Math.max(0, Math.round((Date.now() - at) / 6e4));
1882
+ if (minutes < 1) return "now";
1883
+ if (minutes < 60) return `${minutes}m`;
1884
+ return `${Math.round(minutes / 60)}h`;
1885
+ }
1822
1886
  function OpenCodeSettingsPage(props) {
1823
1887
  const { t } = props;
1824
1888
  const [state, setState] = (0, react.useState)(null);
@@ -1834,6 +1898,7 @@ window.__ModuleLoader__.load({
1834
1898
  const [testing, setTesting] = (0, react.useState)(false);
1835
1899
  const [testResult, setTestResult] = (0, react.useState)(null);
1836
1900
  const [importing, setImporting] = (0, react.useState)(false);
1901
+ const [checking, setChecking] = (0, react.useState)(null);
1837
1902
  const load = (0, react.useCallback)(async () => {
1838
1903
  setLoading(true);
1839
1904
  try {
@@ -1875,6 +1940,18 @@ window.__ModuleLoader__.load({
1875
1940
  setSaving(false);
1876
1941
  }
1877
1942
  };
1943
+ const checkKey = async (label) => {
1944
+ setChecking(label);
1945
+ setError(null);
1946
+ try {
1947
+ await checkAccount(label);
1948
+ await load();
1949
+ } catch (caught) {
1950
+ setError(caught instanceof Error ? caught.message : t("failed"));
1951
+ } finally {
1952
+ setChecking(null);
1953
+ }
1954
+ };
1878
1955
  const refresh = async () => {
1879
1956
  try {
1880
1957
  const next = await refreshState();
@@ -2125,7 +2202,11 @@ window.__ModuleLoader__.load({
2125
2202
  className: "oc-badge oc-badge-bad",
2126
2203
  children: mark
2127
2204
  });
2128
- })()
2205
+ })(),
2206
+ defaultAccount === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2207
+ className: `oc-badge ${keyStatusClass(defaultAccount.keyStatus)}`,
2208
+ children: keyStatusLabel(defaultAccount.keyStatus, t)
2209
+ })
2129
2210
  ]
2130
2211
  }),
2131
2212
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -2248,6 +2329,62 @@ window.__ModuleLoader__.load({
2248
2329
  })
2249
2330
  ]
2250
2331
  }),
2332
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2333
+ className: "oc-card",
2334
+ children: [
2335
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2336
+ className: "oc-card-title",
2337
+ children: [t("serviceStatus"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2338
+ className: `oc-badge ${state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-warn"}`,
2339
+ children: state.service.usageError === void 0 ? t("serviceOk") : t("serviceDegraded")
2340
+ })]
2341
+ }),
2342
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2343
+ className: "oc-row",
2344
+ children: [
2345
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2346
+ className: "oc-hint",
2347
+ children: t("usageEndpoint")
2348
+ }),
2349
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2350
+ className: `oc-badge ${state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2351
+ children: state.service.usageCheckedAt === void 0 ? t("notChecked") : t("checkedAt").replace("{time}", new Date(state.service.usageCheckedAt).toLocaleTimeString())
2352
+ }),
2353
+ state.service.usageLatencyMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2354
+ className: "oc-badge",
2355
+ children: t("latencyValue").replace("{ms}", String(state.service.usageLatencyMs))
2356
+ })
2357
+ ]
2358
+ }),
2359
+ state.service.usageError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2360
+ className: "oc-hint",
2361
+ children: state.service.usageError
2362
+ }),
2363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2364
+ className: "oc-row",
2365
+ children: [
2366
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2367
+ className: "oc-hint",
2368
+ children: t("catalogStatus")
2369
+ }),
2370
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2371
+ className: `oc-badge ${state.catalog.source === "live" ? "oc-badge-ok" : "oc-badge-warn"}`,
2372
+ children: [
2373
+ state.catalog.source === "live" ? t("catalogLive") : t("catalogSnapshot"),
2374
+ " · ",
2375
+ state.catalog.count,
2376
+ " ",
2377
+ t("modelCount")
2378
+ ]
2379
+ }),
2380
+ state.catalog.fetchedAt === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2381
+ className: "oc-badge",
2382
+ children: t("catalogAge").replace("{age}", ageText(state.catalog.fetchedAt))
2383
+ })
2384
+ ]
2385
+ })
2386
+ ]
2387
+ }),
2251
2388
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2252
2389
  className: "oc-card",
2253
2390
  children: [
@@ -2310,6 +2447,10 @@ window.__ModuleLoader__.load({
2310
2447
  className: "oc-hint",
2311
2448
  children: t("accountsHint")
2312
2449
  }),
2450
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2451
+ className: "oc-hint",
2452
+ children: t("keyStatusHint")
2453
+ }),
2313
2454
  draft.accounts.map((account, index) => {
2314
2455
  const known = state.accounts.find((row) => account.apiKeyEnv !== "" && row.ref === account.apiKeyEnv || account.label !== "" && row.label === account.label);
2315
2456
  const configured = known?.configured === true;
@@ -2348,6 +2489,10 @@ window.__ModuleLoader__.load({
2348
2489
  className: `oc-badge ${configured ? "oc-badge-ok" : "oc-badge-warn"}`,
2349
2490
  children: configured ? t("keyConfigured") : t("keyMissing")
2350
2491
  }),
2492
+ known === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2493
+ className: `oc-badge ${keyStatusClass(known.keyStatus)}`,
2494
+ children: keyStatusLabel(known.keyStatus, t)
2495
+ }),
2351
2496
  known === void 0 || known.mark === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2352
2497
  className: "oc-badge oc-badge-bad",
2353
2498
  children: accountMark(known, t)
@@ -2401,6 +2546,15 @@ window.__ModuleLoader__.load({
2401
2546
  },
2402
2547
  children: t("saveKey")
2403
2548
  }),
2549
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2550
+ type: "button",
2551
+ className: "oc-button",
2552
+ disabled: known === void 0 || checking === known.label,
2553
+ onClick: () => {
2554
+ if (known !== void 0) checkKey(known.label);
2555
+ },
2556
+ children: checking === known?.label ? t("loading") : t("checkKey")
2557
+ }),
2404
2558
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2405
2559
  type: "button",
2406
2560
  className: "oc-button",
package/lib/index.d.ts CHANGED
@@ -190,6 +190,8 @@ declare class OpenCodeAdapter extends LlmAdapter {
190
190
  private stickyWrites;
191
191
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
192
192
  private readonly cooldowns;
193
+ /** key + model → short soft rejection; other models stay usable. */
194
+ private readonly modelCooldowns;
193
195
  private readonly sticky;
194
196
  private readonly routing;
195
197
  private warnedTools;
@@ -215,6 +217,11 @@ declare class OpenCodeAdapter extends LlmAdapter {
215
217
  private send;
216
218
  private keyId;
217
219
  private coolingDown;
220
+ /** Current model-scoped soft cooldown for one key, if any. */
221
+ modelHealth(key: string, model: string): {
222
+ coolingUntil: number;
223
+ reason: string;
224
+ } | undefined;
218
225
  /** Current cooldown for one resolved key, if any. */
219
226
  accountHealth(key: string): {
220
227
  coolingUntil: number;
@@ -225,6 +232,9 @@ declare class OpenCodeAdapter extends LlmAdapter {
225
232
  private loadCooldowns;
226
233
  private pruneCooldowns;
227
234
  private persistCooldowns;
235
+ private modelCoolingDown;
236
+ private markRejection;
237
+ private markModelCooldown;
228
238
  private markCooldown;
229
239
  private isTransient;
230
240
  }
@@ -242,9 +252,12 @@ interface UsageReport {
242
252
  readonly monthly?: UsageWindow;
243
253
  };
244
254
  }
255
+ type UsageKeyStatus = 'ok' | 'invalid' | 'expired' | 'missing' | 'unavailable';
245
256
  interface UsageResult {
246
257
  readonly report?: UsageReport;
247
258
  readonly error?: string;
259
+ readonly keyStatus?: UsageKeyStatus;
260
+ readonly latencyMs?: number;
248
261
  readonly checkedAt: number;
249
262
  }
250
263
  interface UsageWindowPayload {
package/lib/index.js CHANGED
@@ -18,7 +18,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
18
18
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
19
19
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
20
20
  const DEFAULT_AUTH_ENTRY = "opencode-go";
21
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.17";
21
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.19";
22
22
  const DEFAULT_CONTEXT_WINDOW = 262144;
23
23
  const DEFAULT_MAX_TOKENS = 32768;
24
24
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -1090,6 +1090,17 @@ function describeModel(meta, provider) {
1090
1090
  }
1091
1091
  return parts.join(" · ");
1092
1092
  }
1093
+ function catalogStatus(provider, config, count) {
1094
+ const cached = catalogCache.get(`${provider}|${modelsUrlFor(provider, config)}`);
1095
+ return cached === void 0 ? {
1096
+ source: "snapshot",
1097
+ count
1098
+ } : {
1099
+ source: "live",
1100
+ count,
1101
+ fetchedAt: cached.at
1102
+ };
1103
+ }
1093
1104
  function clearCatalogCache() {
1094
1105
  catalogCache.clear();
1095
1106
  }
@@ -3100,6 +3111,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3100
3111
  stickyWrites = Promise.resolve();
3101
3112
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
3102
3113
  cooldowns = /* @__PURE__ */ new Map();
3114
+ /** key + model → short soft rejection; other models stay usable. */
3115
+ modelCooldowns = /* @__PURE__ */ new Map();
3103
3116
  sticky = /* @__PURE__ */ new Map();
3104
3117
  routing = [];
3105
3118
  warnedTools = false;
@@ -3202,7 +3215,10 @@ var OpenCodeAdapter = class extends LlmAdapter {
3202
3215
  }
3203
3216
  }
3204
3217
  }
3205
- const initial = keys.find((entry) => fallback || !this.coolingDown(entry.key)) ?? keys[0];
3218
+ const eligible = fallback ? keys : keys.filter((entry) => !this.coolingDown(entry.key) && !this.modelCoolingDown(entry.key, options.model));
3219
+ const ordered = eligible.length > 0 ? eligible : keys;
3220
+ const forced = !fallback && eligible.length === 0;
3221
+ const initial = ordered[0];
3206
3222
  const url = endpointFor(options.provider, model.transport, options.model, config);
3207
3223
  let images;
3208
3224
  if (hasImages(options)) {
@@ -3226,8 +3242,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3226
3242
  let lastError;
3227
3243
  let lastAccount = initial?.label ?? "default";
3228
3244
  let loggedAny = false;
3229
- for (const [index, entry] of keys.entries()) {
3230
- if (!fallback && this.coolingDown(entry.key)) continue;
3245
+ for (const [index, entry] of ordered.entries()) {
3246
+ if (!fallback && !forced && (this.coolingDown(entry.key) || this.modelCoolingDown(entry.key, options.model))) continue;
3231
3247
  lastAccount = entry.label ?? "default";
3232
3248
  const attemptReason = index === 0 ? reason : "rotation";
3233
3249
  const attemptStartedAt = Date.now();
@@ -3254,9 +3270,9 @@ var OpenCodeAdapter = class extends LlmAdapter {
3254
3270
  const error = classifyResponseError(response.status, text, response.headers);
3255
3271
  lastError = error;
3256
3272
  logAttempt(false, error.code);
3257
- const canRotate = index < keys.length - 1;
3273
+ const canRotate = index < ordered.length - 1;
3258
3274
  if (error.code === "INVALID_CREDENTIAL" || error.code === "RATE_LIMIT" || error.code === "QUOTA") {
3259
- if (!fallback) this.markCooldown(entry.key, response.status, response.headers, error.code);
3275
+ if (!fallback) this.markRejection(entry.key, options.model, response.status, response.headers, error.code);
3260
3276
  responseFailure = true;
3261
3277
  if (canRotate) continue;
3262
3278
  }
@@ -3297,8 +3313,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3297
3313
  }
3298
3314
  lastError = classified;
3299
3315
  const transient = this.isTransient(classified);
3300
- if (transient && !responseFailure && !fallback) this.markCooldown(entry.key, 0);
3301
- if (!(index < keys.length - 1 && transient)) {
3316
+ if (transient && !responseFailure && !fallback) this.markRejection(entry.key, options.model, 0, void 0, classified.code);
3317
+ if (!(index < ordered.length - 1 && transient)) {
3302
3318
  if (!fallback && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && classified.code === "QUOTA") break;
3303
3319
  throw classified;
3304
3320
  }
@@ -3453,6 +3469,20 @@ var OpenCodeAdapter = class extends LlmAdapter {
3453
3469
  }
3454
3470
  return true;
3455
3471
  }
3472
+ /** Current model-scoped soft cooldown for one key, if any. */
3473
+ modelHealth(key, model) {
3474
+ const id = `${this.keyId(key)}:${model}`;
3475
+ const entry = this.modelCooldowns.get(id);
3476
+ if (entry === void 0) return void 0;
3477
+ if (entry.until <= Date.now()) {
3478
+ this.modelCooldowns.delete(id);
3479
+ return;
3480
+ }
3481
+ return {
3482
+ coolingUntil: entry.until,
3483
+ reason: entry.reason
3484
+ };
3485
+ }
3456
3486
  /** Current cooldown for one resolved key, if any. */
3457
3487
  accountHealth(key) {
3458
3488
  const id = this.keyId(key);
@@ -3505,6 +3535,30 @@ var OpenCodeAdapter = class extends LlmAdapter {
3505
3535
  await writeFile(this.cooldownPath, payload, "utf8");
3506
3536
  }).catch(() => {});
3507
3537
  }
3538
+ modelCoolingDown(key, model) {
3539
+ const id = `${this.keyId(key)}:${model}`;
3540
+ const entry = this.modelCooldowns.get(id);
3541
+ if (entry === void 0) return false;
3542
+ if (entry.until <= Date.now()) {
3543
+ this.modelCooldowns.delete(id);
3544
+ return false;
3545
+ }
3546
+ return true;
3547
+ }
3548
+ markRejection(key, model, status, headers, code) {
3549
+ if (code === "QUOTA" || code === "RATE_LIMIT") {
3550
+ this.markModelCooldown(key, model, code);
3551
+ return;
3552
+ }
3553
+ this.markCooldown(key, status, headers, code);
3554
+ }
3555
+ markModelCooldown(key, model, code) {
3556
+ const delayMs = code === "QUOTA" ? 3e5 : 6e4;
3557
+ this.modelCooldowns.set(`${this.keyId(key)}:${model}`, {
3558
+ until: Date.now() + delayMs,
3559
+ reason: code === "QUOTA" ? "quota" : "rate-limit"
3560
+ });
3561
+ }
3508
3562
  markCooldown(key, status, headers, code) {
3509
3563
  let delayMs = 3e4;
3510
3564
  let reason = "transport";
@@ -4737,6 +4791,8 @@ async function buildState(ctx, deps) {
4737
4791
  source = hit.source;
4738
4792
  }
4739
4793
  const health = hit === void 0 ? void 0 : deps.adapter.accountHealth(hit.key);
4794
+ const usage = hit === void 0 ? null : usageByLabel.get(slot.label) ?? null;
4795
+ const keyStatus = !configured ? "missing" : health?.reason === "invalid-credential" ? "invalid" : usage?.keyStatus ?? "unchecked";
4740
4796
  accounts.push({
4741
4797
  ...slot,
4742
4798
  configured,
@@ -4744,7 +4800,8 @@ async function buildState(ctx, deps) {
4744
4800
  active: activeLabel === slot.label,
4745
4801
  coolingUntil: health?.coolingUntil ?? 0,
4746
4802
  mark: health?.reason ?? "",
4747
- usage: hit === void 0 ? null : usageByLabel.get(slot.label) ?? null
4803
+ keyStatus,
4804
+ usage
4748
4805
  });
4749
4806
  }
4750
4807
  let models = [];
@@ -4776,7 +4833,13 @@ async function buildState(ctx, deps) {
4776
4833
  key: keyInfo,
4777
4834
  accounts,
4778
4835
  models,
4836
+ catalog: catalogStatus(GO_PROVIDER, config, models.length),
4779
4837
  usage: usage ?? null,
4838
+ service: {
4839
+ ...usage?.checkedAt === void 0 ? {} : { usageCheckedAt: usage.checkedAt },
4840
+ ...usage?.latencyMs === void 0 ? {} : { usageLatencyMs: usage.latencyMs },
4841
+ ...usage?.error === void 0 ? {} : { usageError: usage.error }
4842
+ },
4780
4843
  routing: deps.adapter.routingTrace(),
4781
4844
  auth: {
4782
4845
  entry: config.openCodeAuthEntry,
@@ -4972,6 +5035,28 @@ function applyRoutes(ctx, web, deps) {
4972
5035
  json(res, 500, { error: error instanceof Error ? error.message : "refresh failed" });
4973
5036
  }
4974
5037
  });
5038
+ route("/dsh-opencode/accounts/check", async (req, res) => {
5039
+ if (!requirePost(req, res)) return;
5040
+ try {
5041
+ const body = await readJson(req);
5042
+ const label = typeof body.label === "string" && body.label.trim() !== "" ? body.label.trim() : "default";
5043
+ const hit = (await deps.pool.resolveAll(GO_PROVIDER).catch(() => [])).find((entry) => (entry.label ?? "default") === label);
5044
+ if (hit === void 0) {
5045
+ json(res, 404, { error: `account "${label}" is not configured` });
5046
+ return;
5047
+ }
5048
+ deps.usage.clear();
5049
+ const result = await deps.usage(GO_PROVIDER, hit.key);
5050
+ const keyStatus = result?.keyStatus ?? "unchecked";
5051
+ json(res, 200, {
5052
+ ok: keyStatus === "ok",
5053
+ keyStatus,
5054
+ ...result?.error === void 0 ? {} : { error: result.error }
5055
+ });
5056
+ } catch (error) {
5057
+ json(res, 500, { error: error instanceof Error ? error.message : "account check failed" });
5058
+ }
5059
+ });
4975
5060
  route("/dsh-opencode/models/refresh", async (req, res) => {
4976
5061
  if (!requirePost(req, res)) return;
4977
5062
  clearCatalogCache();
@@ -5324,6 +5409,7 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5324
5409
  } catch {
5325
5410
  return {
5326
5411
  error: "no credential",
5412
+ keyStatus: "missing",
5327
5413
  checkedAt: Date.now()
5328
5414
  };
5329
5415
  }
@@ -5334,7 +5420,9 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5334
5420
  if (Date.now() - cached.checkedAt < ttl) return cached;
5335
5421
  }
5336
5422
  const result = async () => {
5423
+ const startedAt = Date.now();
5337
5424
  const signal = AbortSignal.timeout(USAGE_TIMEOUT_MS);
5425
+ const latency = () => Math.max(0, Date.now() - startedAt);
5338
5426
  try {
5339
5427
  let response;
5340
5428
  let lastNetworkError;
@@ -5355,21 +5443,32 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5355
5443
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
5356
5444
  }
5357
5445
  if (response === void 0) throw lastNetworkError ?? /* @__PURE__ */ new Error("usage request failed");
5358
- if (response.status === 401 || response.status === 403) return {
5359
- error: "usage endpoint rejected the key (inference may still work)",
5360
- checkedAt: Date.now()
5361
- };
5446
+ if (response.status === 401 || response.status === 403) {
5447
+ const body = await response.text().catch(() => "");
5448
+ return {
5449
+ error: "usage endpoint rejected the key (inference may still work)",
5450
+ keyStatus: /expired/i.test(body) ? "expired" : "invalid",
5451
+ latencyMs: latency(),
5452
+ checkedAt: Date.now()
5453
+ };
5454
+ }
5362
5455
  if (!response.ok) return {
5363
5456
  error: `usage endpoint returned HTTP ${response.status}`,
5457
+ keyStatus: "unavailable",
5458
+ latencyMs: latency(),
5364
5459
  checkedAt: Date.now()
5365
5460
  };
5366
5461
  return {
5367
5462
  report: normalizeUsage(await response.json()),
5463
+ keyStatus: "ok",
5464
+ latencyMs: latency(),
5368
5465
  checkedAt: Date.now()
5369
5466
  };
5370
5467
  } catch (error) {
5371
5468
  return {
5372
5469
  error: error instanceof Error ? error.message : "usage request failed",
5470
+ keyStatus: "unavailable",
5471
+ latencyMs: latency(),
5373
5472
  checkedAt: Date.now()
5374
5473
  };
5375
5474
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,17 +54,16 @@
54
54
  },
55
55
  "compatibility": {
56
56
  "dshReleases": {
57
- "0.2.0-rc.2": "compatible",
58
57
  "0.2.1-rc.2": "compatible",
59
58
  "0.2.1-rc.3": "compatible",
60
59
  "0.2.1-rc.4": "compatible"
61
60
  },
62
- "dsh": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4"
61
+ "dsh": ">=0.2.1-rc.2 <0.2.2-0"
63
62
  }
64
63
  },
65
64
  "engines": {
66
65
  "node": ">=22",
67
- "dsh": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4"
66
+ "dsh": ">=0.2.1-rc.2 <0.2.2-0"
68
67
  },
69
68
  "scripts": {
70
69
  "build": "tsdown",
@@ -72,6 +71,8 @@
72
71
  "typecheck": "tsc --noEmit",
73
72
  "test": "node --import tsx --test tests/core.test.ts tests/routes.test.ts tests/limits.test.ts tests/analytics.test.ts tests/adapter.test.ts tests/usage.test.ts tests/request-log.test.ts tests/account-rules.test.ts tests/commands.test.ts tests/tool-schema.test.ts tests/tui-settings.test.ts tests/plan-filter.test.ts tests/image-budget.test.ts tests/tools.test.ts tests/plugin-updater.test.ts tests/client-render.test.tsx",
74
73
  "check": "npm run typecheck && npm test && npm run build",
74
+ "test:engine": "node scripts/test-engines.mjs",
75
+ "test:install": "node scripts/test-engines.mjs --install-only",
75
76
  "release": "npm run check && npm pack",
76
77
  "sync:models": "node scripts/sync-models.mjs"
77
78
  },
@@ -84,19 +85,19 @@
84
85
  "llm-provider"
85
86
  ],
86
87
  "peerDependencies": {
87
- "@deepseek-ai/cordis": "~4.0.4 || ~4.0.5",
88
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
89
- "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
90
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
91
- "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
92
- "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
93
- "@deepseek-ai/dsh-commands": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
94
- "@deepseek-ai/dsh-credentials": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
95
- "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
96
- "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
97
- "@deepseek-ai/dsh-llm": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
98
- "@deepseek-ai/dsh-settings": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
99
- "@deepseek-ai/schemastery": "~3.18.4 || ~3.18.5",
88
+ "@deepseek-ai/cordis": "~4.0.5",
89
+ "@deepseek-ai/dsh-attachment": ">=0.2.1-rc.2 <0.2.2-0",
90
+ "@deepseek-ai/dsh-client-locale": ">=0.2.1-rc.2 <0.2.2-0",
91
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.2.1-rc.2 <0.2.2-0",
92
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.2.1-rc.2 <0.2.2-0",
93
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.2.1-rc.2 <0.2.2-0",
94
+ "@deepseek-ai/dsh-commands": ">=0.2.1-rc.2 <0.2.2-0",
95
+ "@deepseek-ai/dsh-credentials": ">=0.2.1-rc.2 <0.2.2-0",
96
+ "@deepseek-ai/dsh-host-webserver": ">=0.2.1-rc.2 <0.2.2-0",
97
+ "@deepseek-ai/dsh-launch-environment": ">=0.2.1-rc.2 <0.2.2-0",
98
+ "@deepseek-ai/dsh-llm": ">=0.2.1-rc.2 <0.2.2-0",
99
+ "@deepseek-ai/dsh-settings": ">=0.2.1-rc.2 <0.2.2-0",
100
+ "@deepseek-ai/schemastery": "~3.18.5",
100
101
  "react": "^18.2.0"
101
102
  },
102
103
  "devDependencies": {