@dan-ai-studio/dshopencodego 0.1.45 → 0.1.47

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/README.en.md CHANGED
@@ -6,12 +6,18 @@ Makes [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) work p
6
6
 
7
7
  ## Why it exists
8
8
 
9
- A stock DSH route to OpenCode Go has two gaps that no configuration can close:
9
+ A stock DSH route to OpenCode Go already works (`llm-pi-ai` ships the provider). What this plugin adds is what that route does **not** do:
10
10
 
11
- 1. **The model list is a build-time snapshot.** DSH's `llm-pi-ai` uses the catalog pi-ai shipped with; when the gateway adds a model, you either wait for a pi-ai release or declare it by hand.
12
- 2. **No session header.** Since 2026-09-05 Go requires a stable `x-opencode-session` on every request and answers `400 MissingSessionID` without it; `llm-pi-ai` sends only static profile headers, which cannot vary per conversation.
11
+ | Capability | Stock DSH | This plugin |
12
+ | --- | --- | --- |
13
+ | **Live model catalog** | A build-time snapshot: the fixed table pi-ai ships with, so a new gateway model waits for a pi-ai release | Reads the gateway catalog live, with multi-level fallback and TTL caching |
14
+ | **Quota windows** (5-hour / weekly / monthly) | None | ✅ |
15
+ | **Estimated spend** | None | ✅ |
16
+ | **Per-model switches** `modelVisibility` | None | ✅ |
17
+
18
+ This plugin serves the `opencode-go` route with its own adapter and adds the usage and settings surfaces.
13
19
 
14
- This plugin serves the `opencode-go` route with its own adapter and closes both gaps.
20
+ > **On the session header**: Go requires a stable `x-opencode-session` on every request and answers `400 MissingSessionID` without it. That was one of this plugin's original reasons to exist, but pi-ai implemented the injection on 2026-09-09 (`561a2e06`) and `llm-pi-ai` carries it automatically, so a stock DSH route does not miss the header either. This plugin still keeps its own implementation — it falls back to a one-off UUID when a session id is missing — until that fallback lands upstream; the review is in [`docs/scope-review-2026-10.md`](docs/scope-review-2026-10.md).
15
21
 
16
22
  ## Install
17
23
 
@@ -241,7 +247,7 @@ npm test # contract tests against a real local HTTP mock gateway
241
247
 
242
248
  **Order matters**: `npm run build` must come before `npm test` — `tests/pack-contents.spec.ts` walks every `exports` target, and whether `lib/` exists decides whether that check runs or is silently skipped (it does not fail, it just stops protecting you). `package.json` no longer carries `prepare`, so `npm ci` will not build for you; all three CI workflows run `npm run build` explicitly.
243
249
 
244
- The suite covers configuration validation, the protocol ladder, gateway and online metadata, the catalog projection, the adapter on the wire (all three protocols), Remote contracts and services, session-header invariants, usage windows and the meter, the client controller and usage pill, both halves' mounting and registration, the error mapping for stream idle timeouts and aborts, the exit-code contract of the verification scripts, and several **structural invariants** (inject declarations match the source's reads, peer coverage holds both ways, the READMEs match the manifest). That is **43 test files** — a number `tests/readme-consistency.spec.ts` checks against the `tests/` directory, so it cannot drift.
250
+ The suite covers configuration validation, the protocol ladder, gateway and online metadata, the catalog projection, the adapter on the wire (all three protocols), Remote contracts and services, session-header invariants, usage windows and the meter, the client controller and usage pill, both halves' mounting and registration, the error mapping for stream idle timeouts and aborts, the exit-code contract of the verification scripts, and several **structural invariants** (inject declarations match the source's reads, peer coverage holds both ways, the READMEs match the manifest, and the READMEs' claims about the upstream match the upstream actually installed). That is **45 test files** — a number `tests/readme-consistency.spec.ts` checks against the `tests/` directory, so it cannot drift.
245
251
 
246
252
  ## Releasing (maintainers)
247
253
 
package/README.md CHANGED
@@ -6,12 +6,18 @@
6
6
 
7
7
  ## 为什么需要它
8
8
 
9
- 原生 DSH 接 OpenCode Go 有两个缺口,都不是配置能补的:
9
+ 原生 DSH 已经能连 OpenCode Go(`llm-pi-ai` 内置该 provider)。本插件补的是它**不提供**的那几件事:
10
10
 
11
- 1. **模型列表是构建期快照**。DSH 的 `llm-pi-ai` 用 pi-ai 打包时生成的目录;网关新增模型后,要么等 pi-ai 发版,要么手工声明。
12
- 2. **没有会话头**。Go 自 2026-09-05 起要求每个请求带稳定的 `x-opencode-session`,缺失即 `400 MissingSessionID`;`llm-pi-ai` 只发静态 profile headers,无法按会话取值。
11
+ | 能力 | 原生 DSH | 本插件 |
12
+ | --- | --- | --- |
13
+ | **实时模型目录** | 构建期快照:pi-ai 打包时生成的固定表,网关新增模型要等 pi-ai 发版 | 实时读取网关目录,多级回退 + TTL 缓存 |
14
+ | **额度窗口**(5 小时 / 本周 / 本月) | 无 | ✅ |
15
+ | **花费估算** | 无 | ✅ |
16
+ | **逐模型开关** `modelVisibility` | 无 | ✅ |
17
+
18
+ 本插件为 `opencode-go` 路由提供自己的适配器,并提供用量与设置界面。
13
19
 
14
- 本插件为 `opencode-go` 路由提供自己的适配器,同时解决这两点。
20
+ > **关于会话头**:Go 要求每个请求带稳定的 `x-opencode-session`,缺失即 `400 MissingSessionID`。这一项曾是本插件的原始存在理由,但 pi-ai 已于 2026-09-09(`561a2e06`)实现注入、`llm-pi-ai` 自动携带,因此**原生 DSH 现在也不会缺会话头**。本插件当前仍保留自己的实现(为缺失会话 id 的请求提供一次性 UUID 兜底),待该兜底进入上游后再收缩——评估过程见 [`docs/scope-review-2026-10.md`](docs/scope-review-2026-10.md)。
15
21
 
16
22
  ## 安装
17
23
 
@@ -241,7 +247,7 @@ npm test # 契约测试:真实 HTTP mock 网关,断言请求头/
241
247
 
242
248
  **顺序有讲究**:`npm run build` 要排在 `npm test` 之前——`tests/pack-contents.spec.ts` 会遍历 `exports` 的每个目标,`lib/` 存在与否决定这条检查是执行还是被跳过(不报错,但会少一层保护)。`package.json` 已移除 `prepare`,因此 `npm ci` 不会再替你隐式构建;三个 CI workflow 都显式跑了 `npm run build`。
243
249
 
244
- 测试覆盖配置校验、协议阶梯、网关与在线元数据、目录投影、适配器(三种协议的线上请求)、Remote 契约与服务、会话头不变量、用量窗口与计量、客户端控制器与用量组件、两半的挂载与登记、流空闲超时与中止的错误映射、验证脚本的退出码契约、以及几条**结构性不变量**(inject 声明与源码读取一致、peer 覆盖双向、README 与 manifest 一致)。共 **43 个测试文件**——这个数字由 `tests/readme-consistency.spec.ts` 对着 `tests/` 目录校验,不会漂移。
250
+ 测试覆盖配置校验、协议阶梯、网关与在线元数据、目录投影、适配器(三种协议的线上请求)、Remote 契约与服务、会话头不变量、用量窗口与计量、客户端控制器与用量组件、两半的挂载与登记、流空闲超时与中止的错误映射、验证脚本的退出码契约、以及几条**结构性不变量**(inject 声明与源码读取一致、peer 覆盖双向、README 与 manifest 一致、README 对上游的断言与实装的上游一致)。共 **45 个测试文件**——这个数字由 `tests/readme-consistency.spec.ts` 对着 `tests/` 目录校验,不会漂移。
245
251
 
246
252
  ## 发布(维护者)
247
253
 
package/lib/client.js CHANGED
@@ -29,6 +29,7 @@ module.exports = __toCommonJS(index_exports);
29
29
  // src/catalog/constants.ts
30
30
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
31
31
  var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
32
+ var UNKNOWN_MODEL_PROBE_COOLDOWN_MS = 5 * 6e4;
32
33
 
33
34
  // src/catalog/metadata.ts
34
35
  var INPUT_MODALITIES = ["text", "image", "audio", "video", "pdf"];
@@ -84,12 +85,27 @@ function parseModel(value) {
84
85
  ...typeof row["releaseDate"] === "string" ? { releaseDate: row["releaseDate"] } : {},
85
86
  ...row["goQuota"] === void 0 ? {} : { goQuota: parseGoQuota(row["goQuota"]) },
86
87
  ...row["cost"] === void 0 ? {} : { cost: parseCost(row["cost"]) },
87
- ...row["protocolSource"] === "builtin" || row["protocolSource"] === "online" || row["protocolSource"] === "inferred" || row["protocolSource"] === "override" ? { protocolSource: row["protocolSource"] } : {},
88
+ // `document` is the provider's own endpoint table — the most authoritative
89
+ // evidence there is — so it must survive the wire. Dropping it made a
90
+ // documented protocol indistinguishable from an unknown one on the page.
91
+ ...row["protocolSource"] === "builtin" || row["protocolSource"] === "document" || row["protocolSource"] === "online" || row["protocolSource"] === "inferred" || row["protocolSource"] === "override" ? { protocolSource: row["protocolSource"] } : {},
88
92
  ...typeof row["assumedLimits"] === "boolean" ? { assumedLimits: row["assumedLimits"] } : {},
89
93
  ...parseModalities(row["inputModalities"]),
90
94
  ...typeof row["configurationMissing"] === "string" ? { configurationMissing: row["configurationMissing"] } : {}
91
95
  };
92
96
  }
97
+ function parseDegradations(value) {
98
+ if (!Array.isArray(value)) return [];
99
+ const kept = [];
100
+ for (const item of value) {
101
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
102
+ const row = item;
103
+ if (row["source"] !== "metadata" && row["source"] !== "document") continue;
104
+ if (typeof row["reason"] !== "string") continue;
105
+ kept.push({ source: row["source"], reason: row["reason"] });
106
+ }
107
+ return kept;
108
+ }
93
109
  function parseCatalogReading(value) {
94
110
  if (value === null || typeof value !== "object") throw new Error("invalid catalog reading");
95
111
  const row = value;
@@ -111,6 +127,7 @@ function parseCatalogReading(value) {
111
127
  models: row["models"].map(parseModel),
112
128
  stale: row["stale"],
113
129
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
130
+ degradations: parseDegradations(row["degradations"]),
114
131
  fetchedAtMs: row["fetchedAtMs"],
115
132
  quotaSource: quotaSource ?? "seed",
116
133
  counts: {
@@ -309,6 +326,9 @@ var en = {
309
326
  catalogSelectAll: "Select all",
310
327
  catalogSelectNone: "Select none",
311
328
  catalogStale: "Showing the last successful listing",
329
+ catalogDegraded: "Catalog degraded",
330
+ catalogDegradedMetadata: "model metadata (models.dev) unavailable",
331
+ catalogDegradedDocument: "provider documentation unavailable",
312
332
  catalogCountUnit: "models",
313
333
  catalogInferred: "protocol inferred",
314
334
  catalogAssumed: "default capacities",
@@ -405,6 +425,9 @@ var zh = {
405
425
  catalogSelectAll: "\u5168\u9009",
406
426
  catalogSelectNone: "\u5168\u4E0D\u9009",
407
427
  catalogStale: "\u5F53\u524D\u663E\u793A\u7684\u662F\u4E0A\u6B21\u6210\u529F\u83B7\u53D6\u7684\u5217\u8868",
428
+ catalogDegraded: "\u76EE\u5F55\u5DF2\u964D\u7EA7",
429
+ catalogDegradedMetadata: "\u6A21\u578B\u5143\u6570\u636E\uFF08models.dev\uFF09\u4E0D\u53EF\u7528",
430
+ catalogDegradedDocument: "\u5382\u5546\u6587\u6863\u4E0D\u53EF\u7528",
408
431
  catalogCountUnit: "\u4E2A\u6A21\u578B",
409
432
  catalogInferred: "\u534F\u8BAE\u4E3A\u63A8\u65AD",
410
433
  catalogAssumed: "\u5BB9\u91CF\u4E3A\u9ED8\u8BA4\u503C",
@@ -1211,6 +1234,11 @@ function Section({ controller, t, getLocale }) {
1211
1234
  state.failure
1212
1235
  ] }),
1213
1236
  reading !== void 0 && reading.stale && state.failure === void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: section_default.warn, children: t("catalogStale") }),
1237
+ reading !== void 0 && reading.degradations.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: section_default.warn, children: [
1238
+ t("catalogDegraded"),
1239
+ ": ",
1240
+ reading.degradations.map((degradation) => `${t(degradation.source === "metadata" ? "catalogDegradedMetadata" : "catalogDegradedDocument")} \u2014 ${degradation.reason}`).join("; ")
1241
+ ] }),
1214
1242
  reading !== void 0 && reading.quotaSource === "seed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: section_default.hint, children: [
1215
1243
  t("quotaSeed"),
1216
1244
  " ",
package/lib/index.js CHANGED
@@ -42530,6 +42530,7 @@ var MODEL_METADATA_PROVIDER = "opencode-go";
42530
42530
  var METADATA_FETCH_TIMEOUT_MS = 1e4;
42531
42531
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
42532
42532
  var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
42533
+ var UNKNOWN_MODEL_PROBE_COOLDOWN_MS = 5 * 6e4;
42533
42534
 
42534
42535
  // src/catalog/gateway.ts
42535
42536
  import { attributionHeaders, LlmError as LlmError2 } from "@deepseek-ai/dsh-llm";
@@ -42626,8 +42627,8 @@ async function fetchModelIds(baseURL, signal) {
42626
42627
  // src/catalog/go-doc.ts
42627
42628
  import { attributionHeaders as attributionHeaders2, LlmError as LlmError3 } from "@deepseek-ai/dsh-llm";
42628
42629
  var GO_DOC_URLS = [
42629
- "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/go.mdx",
42630
- "https://cdn.jsdelivr.net/gh/anomalyco/opencode@dev/packages/web/src/content/docs/go.mdx"
42630
+ "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/go.mdx",
42631
+ "https://cdn.jsdelivr.net/gh/anomalyco/opencode@dev/services/www/src/docs/content/console/go.mdx"
42631
42632
  ];
42632
42633
  var GO_DOC_MAX_BYTES = 1024 * 1024;
42633
42634
  var ENDPOINT_PROTOCOLS = {
@@ -42716,7 +42717,9 @@ async function readSource(url, signal) {
42716
42717
  return { failure: `${url} answered HTTP ${response.status}` };
42717
42718
  }
42718
42719
  const document = parseGoDocument(await readBoundedText(response, GO_DOC_MAX_BYTES));
42719
- if (document.quotas.size === 0) return { failure: `${url} carried no allowance table` };
42720
+ if (document.quotas.size === 0 && document.protocols.size === 0) {
42721
+ return { failure: `${url} carried neither an allowance table nor an endpoint table` };
42722
+ }
42720
42723
  return { document };
42721
42724
  } catch (error) {
42722
42725
  return { failure: `${url}: ${error instanceof Error ? error.message : String(error)}` };
@@ -42949,6 +42952,10 @@ function toPiModel(facts, baseURL) {
42949
42952
  }
42950
42953
 
42951
42954
  // src/catalog/index.ts
42955
+ function reasonText(reason) {
42956
+ if (reason instanceof Error) return reason.message;
42957
+ return typeof reason === "string" ? reason : String(reason);
42958
+ }
42952
42959
  function builtinModels(baseURL) {
42953
42960
  return new Map(Object.values(OPENCODE_GO_MODELS).map((model) => [model.id, {
42954
42961
  ...model,
@@ -42994,6 +43001,8 @@ var OpencodeGoCatalog = class {
42994
43001
  lastMetadata;
42995
43002
  /** The last parsed documentation; retained across failed fetches. */
42996
43003
  document;
43004
+ /** Ids that already forced a revalidation, with the time they did. */
43005
+ probedAt = /* @__PURE__ */ new Map();
42997
43006
  constructor(options) {
42998
43007
  this.options = options;
42999
43008
  }
@@ -43030,7 +43039,8 @@ var OpencodeGoCatalog = class {
43030
43039
  */
43031
43040
  async forModel(id, signal) {
43032
43041
  let snapshot2 = await this.snapshot();
43033
- if (!snapshot2.facts.has(id) && !snapshot2.unavailable.has(id)) {
43042
+ if (!snapshot2.facts.has(id) && !snapshot2.unavailable.has(id) && this.mayProbe(id)) {
43043
+ this.probedAt.set(id, Date.now());
43034
43044
  snapshot2 = await this.snapshot(true, signal);
43035
43045
  }
43036
43046
  const reason = snapshot2.unavailable.get(id);
@@ -43042,6 +43052,22 @@ var OpencodeGoCatalog = class {
43042
43052
  }
43043
43053
  return snapshot2;
43044
43054
  }
43055
+ /**
43056
+ * Whether this id may revalidate the catalog again.
43057
+ *
43058
+ * The revalidation is what makes a newly published model usable without a
43059
+ * restart, so it cannot simply be dropped. It is also the most expensive
43060
+ * thing this route does — the metadata document is megabytes — and the host
43061
+ * resolves a model on every list and on every new session. An id that never
43062
+ * resolves would therefore pay that cost forever, which is how a plugin
43063
+ * makes a host slow down over time rather than start slow.
43064
+ * @param id - the model id that was not found.
43065
+ * @returns true when the id may force a revalidation now.
43066
+ */
43067
+ mayProbe(id) {
43068
+ const last = this.probedAt.get(id);
43069
+ return last === void 0 || Date.now() - last >= UNKNOWN_MODEL_PROBE_COOLDOWN_MS;
43070
+ }
43045
43071
  /**
43046
43072
  * Revalidate every source, reusing the metadata document when it is unchanged.
43047
43073
  * @param signal - caller cancellation, forwarded to the documentation read.
@@ -43054,7 +43080,9 @@ var OpencodeGoCatalog = class {
43054
43080
  this.refreshMetadata(builtin),
43055
43081
  readDocument(signal)
43056
43082
  ]);
43083
+ const degradations = [];
43057
43084
  if (metadata.status === "rejected") {
43085
+ degradations.push({ source: "metadata", reason: reasonText(metadata.reason) });
43058
43086
  this.options.observers?.onFallback?.({
43059
43087
  url: MODEL_METADATA_URL,
43060
43088
  error: metadata.reason,
@@ -43064,6 +43092,7 @@ var OpencodeGoCatalog = class {
43064
43092
  if (documentation.status === "fulfilled") {
43065
43093
  this.document = documentation.value;
43066
43094
  } else {
43095
+ degradations.push({ source: "document", reason: reasonText(documentation.reason) });
43067
43096
  this.options.observers?.onFallback?.({
43068
43097
  url: GO_DOC_URLS[0] ?? "the Go documentation",
43069
43098
  error: documentation.reason,
@@ -43084,6 +43113,7 @@ var OpencodeGoCatalog = class {
43084
43113
  provider: buildProvider2(this.options.baseURL, [...facts.values()].map((fact) => toPiModel(fact, this.options.baseURL))),
43085
43114
  live: false,
43086
43115
  listingFailure: listing.reason,
43116
+ ...degradations.length === 0 ? {} : { degradations },
43087
43117
  fetchedAtMs: Date.now(),
43088
43118
  ...this.documentQuotas()
43089
43119
  };
@@ -43091,17 +43121,18 @@ var OpencodeGoCatalog = class {
43091
43121
  return this.assemble(
43092
43122
  listing.value,
43093
43123
  metadata.status === "fulfilled" ? metadata.value : this.lastMetadata,
43094
- builtin
43124
+ builtin,
43125
+ degradations
43095
43126
  );
43096
43127
  }
43097
43128
  /** Merge the listing with the metadata document and the installed entries. */
43098
- assemble(ids, online, builtin) {
43129
+ assemble(ids, online, builtin, degradations = []) {
43099
43130
  const facts = /* @__PURE__ */ new Map();
43100
43131
  const unavailable = /* @__PURE__ */ new Map();
43101
43132
  for (const id of ids) {
43102
43133
  const parsed = online?.models.get(id);
43103
43134
  if (parsed !== void 0) {
43104
- facts.set(id, parsed);
43135
+ facts.set(id, this.withDocumentedProtocol(parsed));
43105
43136
  continue;
43106
43137
  }
43107
43138
  const reason = online?.errors.get(id);
@@ -43126,13 +43157,22 @@ var OpencodeGoCatalog = class {
43126
43157
  unavailable,
43127
43158
  provider: buildProvider2(this.options.baseURL, [...facts.values()].map((fact) => toPiModel(fact, this.options.baseURL))),
43128
43159
  live: true,
43160
+ ...degradations.length === 0 ? {} : { degradations },
43129
43161
  fetchedAtMs: Date.now(),
43130
43162
  ...this.documentQuotas()
43131
43163
  };
43132
43164
  }
43133
- /** The document allowances as a snapshot field, absent until one fetch lands. */
43165
+ /**
43166
+ * The document allowances as a snapshot field, absent until one fetch lands.
43167
+ *
43168
+ * A document that carries no allowance table leaves the field absent rather
43169
+ * than empty, so the frozen seed in `go-limits.ts` answers instead. An empty
43170
+ * map would look like "the provider states no allowance for any model", and
43171
+ * that is a different claim from "this build has no live allowances".
43172
+ */
43134
43173
  documentQuotas() {
43135
- return this.document === void 0 ? {} : { documentQuotas: this.document.quotas };
43174
+ if (this.document === void 0 || this.document.quotas.size === 0) return {};
43175
+ return { documentQuotas: this.document.quotas };
43136
43176
  }
43137
43177
  /**
43138
43178
  * Prefer the documented protocol over anything below the installed catalog.
@@ -45272,12 +45312,27 @@ function parseModel(value) {
45272
45312
  ...typeof row["releaseDate"] === "string" ? { releaseDate: row["releaseDate"] } : {},
45273
45313
  ...row["goQuota"] === void 0 ? {} : { goQuota: parseGoQuota(row["goQuota"]) },
45274
45314
  ...row["cost"] === void 0 ? {} : { cost: parseCost(row["cost"]) },
45275
- ...row["protocolSource"] === "builtin" || row["protocolSource"] === "online" || row["protocolSource"] === "inferred" || row["protocolSource"] === "override" ? { protocolSource: row["protocolSource"] } : {},
45315
+ // `document` is the provider's own endpoint table — the most authoritative
45316
+ // evidence there is — so it must survive the wire. Dropping it made a
45317
+ // documented protocol indistinguishable from an unknown one on the page.
45318
+ ...row["protocolSource"] === "builtin" || row["protocolSource"] === "document" || row["protocolSource"] === "online" || row["protocolSource"] === "inferred" || row["protocolSource"] === "override" ? { protocolSource: row["protocolSource"] } : {},
45276
45319
  ...typeof row["assumedLimits"] === "boolean" ? { assumedLimits: row["assumedLimits"] } : {},
45277
45320
  ...parseModalities(row["inputModalities"]),
45278
45321
  ...typeof row["configurationMissing"] === "string" ? { configurationMissing: row["configurationMissing"] } : {}
45279
45322
  };
45280
45323
  }
45324
+ function parseDegradations(value) {
45325
+ if (!Array.isArray(value)) return [];
45326
+ const kept = [];
45327
+ for (const item of value) {
45328
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
45329
+ const row = item;
45330
+ if (row["source"] !== "metadata" && row["source"] !== "document") continue;
45331
+ if (typeof row["reason"] !== "string") continue;
45332
+ kept.push({ source: row["source"], reason: row["reason"] });
45333
+ }
45334
+ return kept;
45335
+ }
45281
45336
  function parseCatalogReading(value) {
45282
45337
  if (value === null || typeof value !== "object") throw new Error("invalid catalog reading");
45283
45338
  const row = value;
@@ -45299,6 +45354,7 @@ function parseCatalogReading(value) {
45299
45354
  models: row["models"].map(parseModel),
45300
45355
  stale: row["stale"],
45301
45356
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
45357
+ degradations: parseDegradations(row["degradations"]),
45302
45358
  fetchedAtMs: row["fetchedAtMs"],
45303
45359
  quotaSource: quotaSource ?? "seed",
45304
45360
  counts: {
@@ -45539,6 +45595,7 @@ function catalogReading(snapshot2, visibility, listingFailure, now) {
45539
45595
  models: sortModels(models, now),
45540
45596
  stale: !snapshot2.live,
45541
45597
  ...!snapshot2.live && listingFailure !== void 0 ? { error: listingFailure } : {},
45598
+ degradations: snapshot2.degradations ?? [],
45542
45599
  fetchedAtMs: snapshot2.fetchedAtMs,
45543
45600
  quotaSource: documentQuotas === void 0 ? "seed" : "document",
45544
45601
  counts: {
@@ -45707,7 +45764,7 @@ function createUpdateChecker(deps) {
45707
45764
  }
45708
45765
 
45709
45766
  // src/update/version.ts
45710
- var PLUGIN_VERSION = true ? "0.1.45" : "0.0.0-dev";
45767
+ var PLUGIN_VERSION = true ? "0.1.47" : "0.0.0-dev";
45711
45768
 
45712
45769
  // src/update/service.ts
45713
45770
  import { TypertRemoteService as TypertRemoteService2 } from "@deepseek-ai/dsh-typert-protocol";
@@ -16,3 +16,19 @@ export declare const METADATA_FETCH_TIMEOUT_MS = 10000;
16
16
  /** Response caps: the listing is tiny, the metadata document is not. */
17
17
  export declare const MODEL_LISTING_MAX_BYTES: number;
18
18
  export declare const MODEL_METADATA_MAX_BYTES: number;
19
+ /**
20
+ * How long one unknown model id waits before it may revalidate the catalog
21
+ * again.
22
+ *
23
+ * An id the catalog has never seen forces one revalidation, because "this
24
+ * build has not seen it" and "it was just published" look the same from here.
25
+ * Without a cooldown, an id that will never resolve — a mistyped id, a model
26
+ * the gateway retired, a stale entry in a session — makes every single model
27
+ * resolution refetch the whole metadata document, which is megabytes. The
28
+ * host resolves a model on every list and on every new session, so that cost
29
+ * is paid over and over for an id whose answer will not change.
30
+ *
31
+ * Five minutes is short enough that a genuinely new model still shows up
32
+ * without a restart, and long enough that a bad id costs one fetch.
33
+ */
34
+ export declare const UNKNOWN_MODEL_PROBE_COOLDOWN_MS: number;
@@ -20,6 +20,13 @@ import type { WireProtocol } from './protocol.ts';
20
20
  *
21
21
  * The repository file is the source of truth; the jsDelivr entry caches the
22
22
  * same path and is tried when GitHub itself is unreachable.
23
+ *
24
+ * The path is the provider's own, and it moves: `go.mdx` used to live at
25
+ * `packages/web/src/content/docs/go.mdx` and the `packages/web` tree was
26
+ * restructured, after which every request answered 404. A document that cannot
27
+ * be read costs the endpoint table — the provider's own statement of each
28
+ * model's protocol — so the path is asserted against the live repository by
29
+ * `tests/go-doc-url.spec.ts` rather than left to rot unnoticed.
23
30
  */
24
31
  export declare const GO_DOC_URLS: readonly string[];
25
32
  /** What one parse of the document yields. */
@@ -37,6 +44,12 @@ export interface GoDoc {
37
44
  * endpoint table and the other two attach by normalised display name. A row
38
45
  * that names no known model, or states no usable number, is skipped rather than
39
46
  * guessed; a tiered model whose rows disagree is dropped entirely.
47
+ *
48
+ * The two tables are read independently because the provider's page has
49
+ * carried them at different times: the endpoint table names each model's
50
+ * protocol, and the allowance table states the monthly allowance. A document
51
+ * that keeps one and drops the other is still worth reading — the one it
52
+ * keeps is evidence the other source cannot supply.
40
53
  * @param markdown - the raw document text.
41
54
  * @returns the parsed allowances and protocols; both may be empty when the
42
55
  * document does not carry the expected tables.
@@ -29,6 +29,22 @@ export { readOnlineMetadata, toPiModel, modelBaseURL } from './metadata.ts';
29
29
  export type { ModelFacts, OnlineMetadata, ModelDefaults } from './metadata.ts';
30
30
  export { decideProtocol, inferProtocol, protocolOfNpm } from './protocol.ts';
31
31
  export type { ProtocolDecision, ProtocolSource, WireProtocol } from './protocol.ts';
32
+ /**
33
+ * One source the catalog could not read.
34
+ *
35
+ * A degradation is not a failure: the snapshot still answers, from the
36
+ * installed catalog and the frozen seed. But it is not the same answer, and
37
+ * the difference is invisible unless it is said out loud — a model whose
38
+ * protocol was inferred reads exactly like one whose protocol the provider
39
+ * documented, and a thinking level that the online metadata would have
40
+ * supplied is simply absent.
41
+ */
42
+ export interface CatalogDegradation {
43
+ /** Which source could not be read. */
44
+ readonly source: 'metadata' | 'document';
45
+ /** Why it failed, in the source's own words. */
46
+ readonly reason: string;
47
+ }
32
48
  /** One resolved view of the gateway's models. */
33
49
  export interface CatalogSnapshot {
34
50
  /** Models that can be called, by id, with the evidence behind each decision. */
@@ -47,6 +63,16 @@ export interface CatalogSnapshot {
47
63
  readonly documentQuotas?: ReadonlyMap<string, GoQuota>;
48
64
  /** Retained for explicit discovery when the listing failed. */
49
65
  readonly listingFailure?: unknown;
66
+ /**
67
+ * Sources this build could not read.
68
+ *
69
+ * Empty when every source answered. Otherwise the reading is degraded: the
70
+ * protocol of a model no installed entry describes falls back to a guess,
71
+ * and the thinking levels the online metadata carries are missing. The page
72
+ * shows this, because a degraded reading that looks whole is the one state
73
+ * a user cannot act on.
74
+ */
75
+ readonly degradations?: readonly CatalogDegradation[];
50
76
  readonly fetchedAtMs: number;
51
77
  }
52
78
  /** Observers the plugin logs through. */
@@ -94,6 +120,8 @@ export declare class OpencodeGoCatalog {
94
120
  private lastMetadata;
95
121
  /** The last parsed documentation; retained across failed fetches. */
96
122
  private document;
123
+ /** Ids that already forced a revalidation, with the time they did. */
124
+ private readonly probedAt;
97
125
  constructor(options: CatalogOptions);
98
126
  /**
99
127
  * The cached snapshot, refreshed when it is older than the configured TTL.
@@ -116,6 +144,19 @@ export declare class OpencodeGoCatalog {
116
144
  * the id but this build cannot configure it, naming the reason.
117
145
  */
118
146
  forModel(id: string, signal?: AbortSignal): Promise<CatalogSnapshot>;
147
+ /**
148
+ * Whether this id may revalidate the catalog again.
149
+ *
150
+ * The revalidation is what makes a newly published model usable without a
151
+ * restart, so it cannot simply be dropped. It is also the most expensive
152
+ * thing this route does — the metadata document is megabytes — and the host
153
+ * resolves a model on every list and on every new session. An id that never
154
+ * resolves would therefore pay that cost forever, which is how a plugin
155
+ * makes a host slow down over time rather than start slow.
156
+ * @param id - the model id that was not found.
157
+ * @returns true when the id may force a revalidation now.
158
+ */
159
+ private mayProbe;
119
160
  /**
120
161
  * Revalidate every source, reusing the metadata document when it is unchanged.
121
162
  * @param signal - caller cancellation, forwarded to the documentation read.
@@ -123,7 +164,14 @@ export declare class OpencodeGoCatalog {
123
164
  private build;
124
165
  /** Merge the listing with the metadata document and the installed entries. */
125
166
  private assemble;
126
- /** The document allowances as a snapshot field, absent until one fetch lands. */
167
+ /**
168
+ * The document allowances as a snapshot field, absent until one fetch lands.
169
+ *
170
+ * A document that carries no allowance table leaves the field absent rather
171
+ * than empty, so the frozen seed in `go-limits.ts` answers instead. An empty
172
+ * map would look like "the provider states no allowance for any model", and
173
+ * that is a different claim from "this build has no live allowances".
174
+ */
127
175
  private documentQuotas;
128
176
  /**
129
177
  * Prefer the documented protocol over anything below the installed catalog.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { ModelSummary } from '../models.ts';
13
13
  import type { CatalogSnapshot } from './index.ts';
14
+ import type { CatalogDegradation } from './index.ts';
14
15
  /** One settings-page reading of the catalog. */
15
16
  export interface CatalogReading {
16
17
  /** Every advertised model, new first, deprecated last. */
@@ -19,6 +20,15 @@ export interface CatalogReading {
19
20
  readonly stale: boolean;
20
21
  /** Why the reading is stale, when it is. */
21
22
  readonly error?: string;
23
+ /**
24
+ * Sources this reading could not read.
25
+ *
26
+ * Empty when every source answered. Otherwise the page is showing a
27
+ * degraded catalog: a protocol that no source describes is a guess, and the
28
+ * thinking levels the online metadata carries are missing. Saying so is the
29
+ * difference between a user who can act and a user who cannot.
30
+ */
31
+ readonly degradations: readonly CatalogDegradation[];
22
32
  /** When the underlying snapshot was built. */
23
33
  readonly fetchedAtMs: number;
24
34
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dan-ai-studio/dshopencodego",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "OpenCode Go provider for DeepSeek Harness: live gateway catalog, per-conversation session header, usage",
5
5
  "type": "module",
6
6
  "license": "MIT",