@goodandready/dsh-opencode-provider 0.5.18 → 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,11 @@
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
+
3
9
  ## 0.5.18
4
10
 
5
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.
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.18.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.18.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.18
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.18.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.18.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.18
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.18.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.18.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.18
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
@@ -25,6 +25,17 @@ window.__ModuleLoader__.load({
25
25
  keyStatusUnchecked: "Key unchecked",
26
26
  checkKey: "Check key",
27
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",
28
39
  keySource: "Source",
29
40
  keyWritable: "Writable",
30
41
  pasteKey: "Paste a key",
@@ -218,6 +229,17 @@ window.__ModuleLoader__.load({
218
229
  keyStatusUnchecked: "未检查",
219
230
  checkKey: "检查密钥",
220
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: "未检查",
221
243
  keySource: "来源",
222
244
  keyWritable: "可写",
223
245
  pasteKey: "粘贴密钥",
@@ -1854,6 +1876,13 @@ window.__ModuleLoader__.load({
1854
1876
  if (status === "expired" || status === "invalid") return "oc-badge-bad";
1855
1877
  return "oc-badge-warn";
1856
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
+ }
1857
1886
  function OpenCodeSettingsPage(props) {
1858
1887
  const { t } = props;
1859
1888
  const [state, setState] = (0, react.useState)(null);
@@ -2300,6 +2329,62 @@ window.__ModuleLoader__.load({
2300
2329
  })
2301
2330
  ]
2302
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
+ }),
2303
2388
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2304
2389
  className: "oc-card",
2305
2390
  children: [
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
  }
@@ -247,6 +257,7 @@ interface UsageResult {
247
257
  readonly report?: UsageReport;
248
258
  readonly error?: string;
249
259
  readonly keyStatus?: UsageKeyStatus;
260
+ readonly latencyMs?: number;
250
261
  readonly checkedAt: number;
251
262
  }
252
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.18";
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";
@@ -4779,7 +4833,13 @@ async function buildState(ctx, deps) {
4779
4833
  key: keyInfo,
4780
4834
  accounts,
4781
4835
  models,
4836
+ catalog: catalogStatus(GO_PROVIDER, config, models.length),
4782
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
+ },
4783
4843
  routing: deps.adapter.routingTrace(),
4784
4844
  auth: {
4785
4845
  entry: config.openCodeAuthEntry,
@@ -5360,7 +5420,9 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5360
5420
  if (Date.now() - cached.checkedAt < ttl) return cached;
5361
5421
  }
5362
5422
  const result = async () => {
5423
+ const startedAt = Date.now();
5363
5424
  const signal = AbortSignal.timeout(USAGE_TIMEOUT_MS);
5425
+ const latency = () => Math.max(0, Date.now() - startedAt);
5364
5426
  try {
5365
5427
  let response;
5366
5428
  let lastNetworkError;
@@ -5386,23 +5448,27 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5386
5448
  return {
5387
5449
  error: "usage endpoint rejected the key (inference may still work)",
5388
5450
  keyStatus: /expired/i.test(body) ? "expired" : "invalid",
5451
+ latencyMs: latency(),
5389
5452
  checkedAt: Date.now()
5390
5453
  };
5391
5454
  }
5392
5455
  if (!response.ok) return {
5393
5456
  error: `usage endpoint returned HTTP ${response.status}`,
5394
5457
  keyStatus: "unavailable",
5458
+ latencyMs: latency(),
5395
5459
  checkedAt: Date.now()
5396
5460
  };
5397
5461
  return {
5398
5462
  report: normalizeUsage(await response.json()),
5399
5463
  keyStatus: "ok",
5464
+ latencyMs: latency(),
5400
5465
  checkedAt: Date.now()
5401
5466
  };
5402
5467
  } catch (error) {
5403
5468
  return {
5404
5469
  error: error instanceof Error ? error.message : "usage request failed",
5405
5470
  keyStatus: "unavailable",
5471
+ latencyMs: latency(),
5406
5472
  checkedAt: Date.now()
5407
5473
  };
5408
5474
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.18",
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",