@dan-ai-studio/dshopencodego 0.1.46 → 0.1.48

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
@@ -247,7 +247,7 @@ npm test # contract tests against a real local HTTP mock gateway
247
247
 
248
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.
249
249
 
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 **44 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 **46 test files** — a number `tests/readme-consistency.spec.ts` checks against the `tests/` directory, so it cannot drift.
251
251
 
252
252
  ## Releasing (maintainers)
253
253
 
package/README.md CHANGED
@@ -247,7 +247,7 @@ npm test # 契约测试:真实 HTTP mock 网关,断言请求头/
247
247
 
248
248
  **顺序有讲究**:`npm run build` 要排在 `npm test` 之前——`tests/pack-contents.spec.ts` 会遍历 `exports` 的每个目标,`lib/` 存在与否决定这条检查是执行还是被跳过(不报错,但会少一层保护)。`package.json` 已移除 `prepare`,因此 `npm ci` 不会再替你隐式构建;三个 CI workflow 都显式跑了 `npm run build`。
249
249
 
250
- 测试覆盖配置校验、协议阶梯、网关与在线元数据、目录投影、适配器(三种协议的线上请求)、Remote 契约与服务、会话头不变量、用量窗口与计量、客户端控制器与用量组件、两半的挂载与登记、流空闲超时与中止的错误映射、验证脚本的退出码契约、以及几条**结构性不变量**(inject 声明与源码读取一致、peer 覆盖双向、README 与 manifest 一致、README 对上游的断言与实装的上游一致)。共 **44 个测试文件**——这个数字由 `tests/readme-consistency.spec.ts` 对着 `tests/` 目录校验,不会漂移。
250
+ 测试覆盖配置校验、协议阶梯、网关与在线元数据、目录投影、适配器(三种协议的线上请求)、Remote 契约与服务、会话头不变量、用量窗口与计量、客户端控制器与用量组件、两半的挂载与登记、流空闲超时与中止的错误映射、验证脚本的退出码契约、以及几条**结构性不变量**(inject 声明与源码读取一致、peer 覆盖双向、README 与 manifest 一致、README 对上游的断言与实装的上游一致)。共 **46 个测试文件**——这个数字由 `tests/readme-consistency.spec.ts` 对着 `tests/` 目录校验,不会漂移。
251
251
 
252
252
  ## 发布(维护者)
253
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
@@ -17788,8 +17788,8 @@ function getBunSandboxEnvValue(name2) {
17788
17788
  if (procEnvCache === null) {
17789
17789
  procEnvCache = /* @__PURE__ */ new Map();
17790
17790
  try {
17791
- const { readFileSync } = __require("node:fs");
17792
- const data = readFileSync("/proc/self/environ", "utf-8");
17791
+ const { readFileSync: readFileSync2 } = __require("node:fs");
17792
+ const data = readFileSync2("/proc/self/environ", "utf-8");
17793
17793
  for (const entry of data.split("\0")) {
17794
17794
  const idx = entry.indexOf("=");
17795
17795
  if (idx > 0) {
@@ -42462,6 +42462,8 @@ Check your ChatGPT usage: ${CHATGPT_USAGE_URL}` : errorMessage;
42462
42462
  });
42463
42463
 
42464
42464
  // src/index.ts
42465
+ import { homedir } from "node:os";
42466
+ import { join as join3 } from "node:path";
42465
42467
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
42466
42468
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
42467
42469
  import * as llm2 from "@deepseek-ai/dsh-llm";
@@ -42530,6 +42532,7 @@ var MODEL_METADATA_PROVIDER = "opencode-go";
42530
42532
  var METADATA_FETCH_TIMEOUT_MS = 1e4;
42531
42533
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
42532
42534
  var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
42535
+ var UNKNOWN_MODEL_PROBE_COOLDOWN_MS = 5 * 6e4;
42533
42536
 
42534
42537
  // src/catalog/gateway.ts
42535
42538
  import { attributionHeaders, LlmError as LlmError2 } from "@deepseek-ai/dsh-llm";
@@ -42625,9 +42628,81 @@ async function fetchModelIds(baseURL, signal) {
42625
42628
 
42626
42629
  // src/catalog/go-doc.ts
42627
42630
  import { attributionHeaders as attributionHeaders2, LlmError as LlmError3 } from "@deepseek-ai/dsh-llm";
42631
+
42632
+ // src/catalog/go-doc-discovery.ts
42633
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
42634
+ import { join as join2 } from "node:path";
42635
+ var DISCOVERY_URL = "https://api.github.com/repos/anomalyco/opencode/git/trees/dev?recursive=1";
42636
+ var DOCUMENT_NAME = /^go[^/]*\.mdx$/i;
42637
+ var DISCOVERY_MAX_BYTES = 8 * 1024 * 1024;
42638
+ var DISCOVERY_TIMEOUT_MS = 1e4;
42639
+ var CACHE_FILE = "go-doc-paths.json";
42640
+ var CACHE_VERSION = 1;
42641
+ function selectDocumentPaths(paths) {
42642
+ const selected = [];
42643
+ for (const path5 of paths) {
42644
+ const name2 = path5.slice(path5.lastIndexOf("/") + 1);
42645
+ if (!DOCUMENT_NAME.test(name2)) continue;
42646
+ if (!/(^|\/)(docs?|content|console)\//i.test(path5) && !/^packages\/web\/src\/content\/docs\//i.test(path5)) continue;
42647
+ selected.push(path5);
42648
+ }
42649
+ return selected.sort((left, right) => right.length - left.length);
42650
+ }
42651
+ function mirrorsOf(path5) {
42652
+ const encoded = path5.split("/").map((segment) => encodeURIComponent(segment)).join("/");
42653
+ return [
42654
+ `https://raw.githubusercontent.com/anomalyco/opencode/dev/${encoded}`,
42655
+ `https://cdn.jsdelivr.net/gh/anomalyco/opencode@dev/${encoded}`
42656
+ ];
42657
+ }
42658
+ async function discoverDocumentPaths(signal, fetchImpl = fetch) {
42659
+ const timeout = AbortSignal.timeout(DISCOVERY_TIMEOUT_MS);
42660
+ try {
42661
+ const response = await fetchImpl(DISCOVERY_URL, {
42662
+ headers: { accept: "application/vnd.github+json", "user-agent": "dshopencodego" },
42663
+ signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout])
42664
+ });
42665
+ if (!response.ok) return [];
42666
+ const body = await response.text();
42667
+ if (body.length > DISCOVERY_MAX_BYTES) return [];
42668
+ const listing = JSON.parse(body);
42669
+ const paths = (listing.tree ?? []).map((entry) => entry.path).filter((path5) => typeof path5 === "string");
42670
+ return selectDocumentPaths(paths);
42671
+ } catch {
42672
+ return [];
42673
+ }
42674
+ }
42675
+ function readCachedPaths(cacheDir) {
42676
+ if (cacheDir === void 0) return [];
42677
+ try {
42678
+ const parsed = JSON.parse(readFileSync(join2(cacheDir, CACHE_FILE), "utf8"));
42679
+ if (parsed.version !== CACHE_VERSION || !Array.isArray(parsed.paths)) return [];
42680
+ return parsed.paths.filter((path5) => typeof path5 === "string");
42681
+ } catch {
42682
+ return [];
42683
+ }
42684
+ }
42685
+ function rememberPaths(cacheDir, discovered) {
42686
+ const known = readCachedPaths(cacheDir);
42687
+ const merged = [];
42688
+ for (const path5 of [...discovered, ...known]) {
42689
+ if (!merged.includes(path5)) merged.push(path5);
42690
+ }
42691
+ if (cacheDir === void 0 || discovered.length === 0) return merged;
42692
+ try {
42693
+ mkdirSync(cacheDir, { recursive: true });
42694
+ const file = { version: CACHE_VERSION, paths: merged, updatedAt: Date.now() };
42695
+ writeFileSync(join2(cacheDir, CACHE_FILE), `${JSON.stringify(file, null, 2)}
42696
+ `, "utf8");
42697
+ } catch {
42698
+ }
42699
+ return merged;
42700
+ }
42701
+
42702
+ // src/catalog/go-doc.ts
42628
42703
  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"
42704
+ "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/go.mdx",
42705
+ "https://cdn.jsdelivr.net/gh/anomalyco/opencode@dev/services/www/src/docs/content/console/go.mdx"
42631
42706
  ];
42632
42707
  var GO_DOC_MAX_BYTES = 1024 * 1024;
42633
42708
  var ENDPOINT_PROTOCOLS = {
@@ -42716,24 +42791,54 @@ async function readSource(url, signal) {
42716
42791
  return { failure: `${url} answered HTTP ${response.status}` };
42717
42792
  }
42718
42793
  const document = parseGoDocument(await readBoundedText(response, GO_DOC_MAX_BYTES));
42719
- if (document.quotas.size === 0) return { failure: `${url} carried no allowance table` };
42794
+ if (document.quotas.size === 0 && document.protocols.size === 0) {
42795
+ return { failure: `${url} carried neither an allowance table nor an endpoint table` };
42796
+ }
42720
42797
  return { document };
42721
42798
  } catch (error) {
42722
42799
  return { failure: `${url}: ${error instanceof Error ? error.message : String(error)}` };
42723
42800
  }
42724
42801
  }
42725
- async function fetchGoDocument(signal) {
42726
- const attempts = await Promise.all(GO_DOC_URLS.map((url) => readSource(url, signal)));
42802
+ async function fetchGoDocument(signal, options = {}) {
42727
42803
  const failures = [];
42728
- for (const attempt of attempts) {
42729
- if ("document" in attempt) return attempt.document;
42730
- failures.push(attempt.failure);
42804
+ const known = await readSources(GO_DOC_URLS, signal);
42805
+ if (known.document !== void 0) return known.document;
42806
+ failures.push(...known.failures);
42807
+ const remembered = readCachedPaths(options.cacheDir).filter((path5) => !GO_DOC_URLS.some((url) => url.endsWith(path5)));
42808
+ if (remembered.length > 0) {
42809
+ const cached = await readSources(remembered.flatMap(mirrorsOf), signal);
42810
+ if (cached.document !== void 0) return cached.document;
42811
+ failures.push(...cached.failures);
42812
+ }
42813
+ const discover = options.discover ?? discoverDocumentPaths;
42814
+ const discovered = await discover(signal);
42815
+ if (discovered.length === 0) {
42816
+ failures.push("the provider repository named no document");
42817
+ } else {
42818
+ const merged = rememberPaths(options.cacheDir, discovered);
42819
+ const fresh = merged.filter((path5) => !GO_DOC_URLS.some((url) => url.endsWith(path5))).flatMap(mirrorsOf);
42820
+ if (fresh.length === 0) {
42821
+ failures.push("the provider repository named the path that was already tried");
42822
+ } else {
42823
+ const result = await readSources(fresh, signal);
42824
+ if (result.document !== void 0) return result.document;
42825
+ failures.push(...result.failures);
42826
+ }
42731
42827
  }
42732
42828
  throw new LlmError3(
42733
42829
  `could not read the Go documentation (${failures.join("; ")})`,
42734
42830
  "DOCUMENT_UNAVAILABLE"
42735
42831
  );
42736
42832
  }
42833
+ async function readSources(urls, signal) {
42834
+ const attempts = await Promise.all(urls.map((url) => readSource(url, signal)));
42835
+ const failures = [];
42836
+ for (const attempt of attempts) {
42837
+ if ("document" in attempt) return { document: attempt.document, failures };
42838
+ failures.push(attempt.failure);
42839
+ }
42840
+ return { failures };
42841
+ }
42737
42842
 
42738
42843
  // src/catalog/protocol.ts
42739
42844
  var WIRE_PROTOCOLS = [
@@ -42949,6 +43054,10 @@ function toPiModel(facts, baseURL) {
42949
43054
  }
42950
43055
 
42951
43056
  // src/catalog/index.ts
43057
+ function reasonText(reason) {
43058
+ if (reason instanceof Error) return reason.message;
43059
+ return typeof reason === "string" ? reason : String(reason);
43060
+ }
42952
43061
  function builtinModels(baseURL) {
42953
43062
  return new Map(Object.values(OPENCODE_GO_MODELS).map((model) => [model.id, {
42954
43063
  ...model,
@@ -42994,6 +43103,8 @@ var OpencodeGoCatalog = class {
42994
43103
  lastMetadata;
42995
43104
  /** The last parsed documentation; retained across failed fetches. */
42996
43105
  document;
43106
+ /** Ids that already forced a revalidation, with the time they did. */
43107
+ probedAt = /* @__PURE__ */ new Map();
42997
43108
  constructor(options) {
42998
43109
  this.options = options;
42999
43110
  }
@@ -43030,7 +43141,8 @@ var OpencodeGoCatalog = class {
43030
43141
  */
43031
43142
  async forModel(id, signal) {
43032
43143
  let snapshot2 = await this.snapshot();
43033
- if (!snapshot2.facts.has(id) && !snapshot2.unavailable.has(id)) {
43144
+ if (!snapshot2.facts.has(id) && !snapshot2.unavailable.has(id) && this.mayProbe(id)) {
43145
+ this.probedAt.set(id, Date.now());
43034
43146
  snapshot2 = await this.snapshot(true, signal);
43035
43147
  }
43036
43148
  const reason = snapshot2.unavailable.get(id);
@@ -43042,19 +43154,37 @@ var OpencodeGoCatalog = class {
43042
43154
  }
43043
43155
  return snapshot2;
43044
43156
  }
43157
+ /**
43158
+ * Whether this id may revalidate the catalog again.
43159
+ *
43160
+ * The revalidation is what makes a newly published model usable without a
43161
+ * restart, so it cannot simply be dropped. It is also the most expensive
43162
+ * thing this route does — the metadata document is megabytes — and the host
43163
+ * resolves a model on every list and on every new session. An id that never
43164
+ * resolves would therefore pay that cost forever, which is how a plugin
43165
+ * makes a host slow down over time rather than start slow.
43166
+ * @param id - the model id that was not found.
43167
+ * @returns true when the id may force a revalidation now.
43168
+ */
43169
+ mayProbe(id) {
43170
+ const last = this.probedAt.get(id);
43171
+ return last === void 0 || Date.now() - last >= UNKNOWN_MODEL_PROBE_COOLDOWN_MS;
43172
+ }
43045
43173
  /**
43046
43174
  * Revalidate every source, reusing the metadata document when it is unchanged.
43047
43175
  * @param signal - caller cancellation, forwarded to the documentation read.
43048
43176
  */
43049
43177
  async build(signal) {
43050
43178
  const builtin = builtinModels(this.options.baseURL);
43051
- const readDocument = this.options.readDocument ?? fetchGoDocument;
43179
+ const readDocument = this.options.readDocument ?? ((signal2) => fetchGoDocument(signal2, { cacheDir: this.options.cacheDir }));
43052
43180
  const [listing, metadata, documentation] = await Promise.allSettled([
43053
43181
  fetchModelIds(this.options.baseURL),
43054
43182
  this.refreshMetadata(builtin),
43055
43183
  readDocument(signal)
43056
43184
  ]);
43185
+ const degradations = [];
43057
43186
  if (metadata.status === "rejected") {
43187
+ degradations.push({ source: "metadata", reason: reasonText(metadata.reason) });
43058
43188
  this.options.observers?.onFallback?.({
43059
43189
  url: MODEL_METADATA_URL,
43060
43190
  error: metadata.reason,
@@ -43064,6 +43194,7 @@ var OpencodeGoCatalog = class {
43064
43194
  if (documentation.status === "fulfilled") {
43065
43195
  this.document = documentation.value;
43066
43196
  } else {
43197
+ degradations.push({ source: "document", reason: reasonText(documentation.reason) });
43067
43198
  this.options.observers?.onFallback?.({
43068
43199
  url: GO_DOC_URLS[0] ?? "the Go documentation",
43069
43200
  error: documentation.reason,
@@ -43084,6 +43215,7 @@ var OpencodeGoCatalog = class {
43084
43215
  provider: buildProvider2(this.options.baseURL, [...facts.values()].map((fact) => toPiModel(fact, this.options.baseURL))),
43085
43216
  live: false,
43086
43217
  listingFailure: listing.reason,
43218
+ ...degradations.length === 0 ? {} : { degradations },
43087
43219
  fetchedAtMs: Date.now(),
43088
43220
  ...this.documentQuotas()
43089
43221
  };
@@ -43091,17 +43223,18 @@ var OpencodeGoCatalog = class {
43091
43223
  return this.assemble(
43092
43224
  listing.value,
43093
43225
  metadata.status === "fulfilled" ? metadata.value : this.lastMetadata,
43094
- builtin
43226
+ builtin,
43227
+ degradations
43095
43228
  );
43096
43229
  }
43097
43230
  /** Merge the listing with the metadata document and the installed entries. */
43098
- assemble(ids, online, builtin) {
43231
+ assemble(ids, online, builtin, degradations = []) {
43099
43232
  const facts = /* @__PURE__ */ new Map();
43100
43233
  const unavailable = /* @__PURE__ */ new Map();
43101
43234
  for (const id of ids) {
43102
43235
  const parsed = online?.models.get(id);
43103
43236
  if (parsed !== void 0) {
43104
- facts.set(id, parsed);
43237
+ facts.set(id, this.withDocumentedProtocol(parsed));
43105
43238
  continue;
43106
43239
  }
43107
43240
  const reason = online?.errors.get(id);
@@ -43126,13 +43259,22 @@ var OpencodeGoCatalog = class {
43126
43259
  unavailable,
43127
43260
  provider: buildProvider2(this.options.baseURL, [...facts.values()].map((fact) => toPiModel(fact, this.options.baseURL))),
43128
43261
  live: true,
43262
+ ...degradations.length === 0 ? {} : { degradations },
43129
43263
  fetchedAtMs: Date.now(),
43130
43264
  ...this.documentQuotas()
43131
43265
  };
43132
43266
  }
43133
- /** The document allowances as a snapshot field, absent until one fetch lands. */
43267
+ /**
43268
+ * The document allowances as a snapshot field, absent until one fetch lands.
43269
+ *
43270
+ * A document that carries no allowance table leaves the field absent rather
43271
+ * than empty, so the frozen seed in `go-limits.ts` answers instead. An empty
43272
+ * map would look like "the provider states no allowance for any model", and
43273
+ * that is a different claim from "this build has no live allowances".
43274
+ */
43134
43275
  documentQuotas() {
43135
- return this.document === void 0 ? {} : { documentQuotas: this.document.quotas };
43276
+ if (this.document === void 0 || this.document.quotas.size === 0) return {};
43277
+ return { documentQuotas: this.document.quotas };
43136
43278
  }
43137
43279
  /**
43138
43280
  * Prefer the documented protocol over anything below the installed catalog.
@@ -44988,6 +45130,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
44988
45130
  },
44989
45131
  overrides: config.modelProtocols,
44990
45132
  ...this.options.readDocument === void 0 ? {} : { readDocument: this.options.readDocument },
45133
+ ...this.options.cacheDir === void 0 ? {} : { cacheDir: this.options.cacheDir },
44991
45134
  observers: {
44992
45135
  ...this.options.onFallback === void 0 ? {} : { onFallback: this.options.onFallback },
44993
45136
  ...this.options.onUnconfigured === void 0 ? {} : { onUnconfigured: this.options.onUnconfigured }
@@ -45272,12 +45415,27 @@ function parseModel(value) {
45272
45415
  ...typeof row["releaseDate"] === "string" ? { releaseDate: row["releaseDate"] } : {},
45273
45416
  ...row["goQuota"] === void 0 ? {} : { goQuota: parseGoQuota(row["goQuota"]) },
45274
45417
  ...row["cost"] === void 0 ? {} : { cost: parseCost(row["cost"]) },
45275
- ...row["protocolSource"] === "builtin" || row["protocolSource"] === "online" || row["protocolSource"] === "inferred" || row["protocolSource"] === "override" ? { protocolSource: row["protocolSource"] } : {},
45418
+ // `document` is the provider's own endpoint table — the most authoritative
45419
+ // evidence there is — so it must survive the wire. Dropping it made a
45420
+ // documented protocol indistinguishable from an unknown one on the page.
45421
+ ...row["protocolSource"] === "builtin" || row["protocolSource"] === "document" || row["protocolSource"] === "online" || row["protocolSource"] === "inferred" || row["protocolSource"] === "override" ? { protocolSource: row["protocolSource"] } : {},
45276
45422
  ...typeof row["assumedLimits"] === "boolean" ? { assumedLimits: row["assumedLimits"] } : {},
45277
45423
  ...parseModalities(row["inputModalities"]),
45278
45424
  ...typeof row["configurationMissing"] === "string" ? { configurationMissing: row["configurationMissing"] } : {}
45279
45425
  };
45280
45426
  }
45427
+ function parseDegradations(value) {
45428
+ if (!Array.isArray(value)) return [];
45429
+ const kept = [];
45430
+ for (const item of value) {
45431
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
45432
+ const row = item;
45433
+ if (row["source"] !== "metadata" && row["source"] !== "document") continue;
45434
+ if (typeof row["reason"] !== "string") continue;
45435
+ kept.push({ source: row["source"], reason: row["reason"] });
45436
+ }
45437
+ return kept;
45438
+ }
45281
45439
  function parseCatalogReading(value) {
45282
45440
  if (value === null || typeof value !== "object") throw new Error("invalid catalog reading");
45283
45441
  const row = value;
@@ -45299,6 +45457,7 @@ function parseCatalogReading(value) {
45299
45457
  models: row["models"].map(parseModel),
45300
45458
  stale: row["stale"],
45301
45459
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
45460
+ degradations: parseDegradations(row["degradations"]),
45302
45461
  fetchedAtMs: row["fetchedAtMs"],
45303
45462
  quotaSource: quotaSource ?? "seed",
45304
45463
  counts: {
@@ -45539,6 +45698,7 @@ function catalogReading(snapshot2, visibility, listingFailure, now) {
45539
45698
  models: sortModels(models, now),
45540
45699
  stale: !snapshot2.live,
45541
45700
  ...!snapshot2.live && listingFailure !== void 0 ? { error: listingFailure } : {},
45701
+ degradations: snapshot2.degradations ?? [],
45542
45702
  fetchedAtMs: snapshot2.fetchedAtMs,
45543
45703
  quotaSource: documentQuotas === void 0 ? "seed" : "document",
45544
45704
  counts: {
@@ -45707,7 +45867,7 @@ function createUpdateChecker(deps) {
45707
45867
  }
45708
45868
 
45709
45869
  // src/update/version.ts
45710
- var PLUGIN_VERSION = true ? "0.1.46" : "0.0.0-dev";
45870
+ var PLUGIN_VERSION = true ? "0.1.48" : "0.0.0-dev";
45711
45871
 
45712
45872
  // src/update/service.ts
45713
45873
  import { TypertRemoteService as TypertRemoteService2 } from "@deepseek-ai/dsh-typert-protocol";
@@ -45844,6 +46004,18 @@ var inject = ["llm"];
45844
46004
  function isLiveConfig(raw) {
45845
46005
  return typeof raw === "object" && raw !== null && typeof raw.enabled === "object" && typeof raw.enabled?.get === "function";
45846
46006
  }
46007
+ function resolveCacheDir(ctx) {
46008
+ const home = ctx.dshHomePath;
46009
+ if (typeof home === "function") {
46010
+ try {
46011
+ return home("cache", "@dan-ai-studio/dshopencodego");
46012
+ } catch {
46013
+ }
46014
+ }
46015
+ const configured = process.env.DSH_HOME?.trim();
46016
+ const root = configured !== void 0 && configured.length > 0 ? configured : join3(homedir(), ".dsh");
46017
+ return join3(root, "cache", "@dan-ai-studio/dshopencodego");
46018
+ }
45847
46019
  function apply(ctx, raw) {
45848
46020
  const live = isLiveConfig(raw) ? raw : void 0;
45849
46021
  const constant = live === void 0 ? PlainConfig(raw ?? {}) : void 0;
@@ -45864,6 +46036,7 @@ function apply(ctx, raw) {
45864
46036
  const adapter = new OpencodeGoAdapter({
45865
46037
  config: current,
45866
46038
  resolveApiKey,
46039
+ cacheDir: resolveCacheDir(ctx),
45867
46040
  imageAccess: {
45868
46041
  resolveAttachments: () => ctx.get("attachments"),
45869
46042
  resolveImageAccess: (attachments, ref) => llm2.resolveImageAttachmentAccess(
@@ -52,6 +52,13 @@ export interface OpencodeGoAdapterOptions {
52
52
  * a fixed document so the suite stays offline. Production omits this.
53
53
  */
54
54
  readonly readDocument?: () => Promise<GoDoc>;
55
+ /**
56
+ * Where the discovered provider-document paths are remembered.
57
+ *
58
+ * Absent means the plugin keeps them in memory only, which costs one
59
+ * discovery per start and nothing else.
60
+ */
61
+ readonly cacheDir?: string;
55
62
  /**
56
63
  * Observe the provider's own usage for one completed call. This is the only
57
64
  * honest source of token counts: the gateway's `/usage` endpoint reports
@@ -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;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Finding the provider's document without being told where it is.
3
+ *
4
+ * The document is the provider's own statement of each model's protocol, and
5
+ * the provider moves it: `go.mdx` lived at
6
+ * `packages/web/src/content/docs/go.mdx` until the `packages/web` tree was
7
+ * restructured, and the plugin then read 404 from both mirrors for as long as
8
+ * the old path was hardcoded. A hardcoded path is a promise the provider did
9
+ * not make.
10
+ *
11
+ * So the path is discovered instead of declared. The discovery is a read-only
12
+ * listing of the provider's own repository, and it runs only when every known
13
+ * path has already failed — the common case costs one request to a file that
14
+ * is there, and the rare case costs one listing.
15
+ *
16
+ * The paths that discovery finds are remembered on disk, as a *set of paths
17
+ * known to have existed*, not as one current path. That distinction matters:
18
+ * a cache holding a single path would hand back a stale path the moment the
19
+ * provider moved the file again, which is exactly the moment the cache is
20
+ * consulted. A set is only ever a list of candidates to try, so a stale entry
21
+ * costs one failed request and nothing else.
22
+ *
23
+ * @module @dan-ai-studio/dshopencodego/catalog/go-doc-discovery
24
+ */
25
+ /** A repository listing is large; the cap keeps a hostile response bounded. */
26
+ export declare const DISCOVERY_MAX_BYTES: number;
27
+ /**
28
+ * The repository paths a discovery run found.
29
+ *
30
+ * A path is only useful if it names the document this plugin parses, and the
31
+ * listing also contains unrelated `go.mdx` files in other packages' fixtures.
32
+ * The path must therefore sit under a documentation tree.
33
+ * @param paths - every repository path the listing mentioned.
34
+ * @returns the paths that name the provider's Go document.
35
+ */
36
+ export declare function selectDocumentPaths(paths: readonly string[]): readonly string[];
37
+ /**
38
+ * The mirrors a repository path can be read from.
39
+ *
40
+ * Two hosts, one file: the repository itself and a CDN that caches it. They
41
+ * fail independently, which is why both are tried.
42
+ * @param path - a repository-relative path.
43
+ * @returns the URLs to read that path from.
44
+ */
45
+ export declare function mirrorsOf(path: string): readonly string[];
46
+ /**
47
+ * Ask the provider's repository where its document is.
48
+ *
49
+ * The listing is unauthenticated, so it is rate-limited. That is why this runs
50
+ * only after every known path has failed, and why the paths it finds are
51
+ * remembered: the next process starts with the answer and never asks.
52
+ * @param signal - caller cancellation.
53
+ * @param fetchImpl - the fetch to use; injected for tests.
54
+ * @returns the repository paths that name the document; empty when the
55
+ * listing could not be read or named nothing.
56
+ */
57
+ export declare function discoverDocumentPaths(signal?: AbortSignal, fetchImpl?: typeof fetch): Promise<readonly string[]>;
58
+ /**
59
+ * The paths the cache remembers, most recently discovered first.
60
+ *
61
+ * A cache that cannot be read is an empty cache: the caller then tries the
62
+ * paths it knows and discovers again.
63
+ * @param cacheDir - the plugin's cache directory.
64
+ * @returns the remembered repository paths.
65
+ */
66
+ export declare function readCachedPaths(cacheDir: string | undefined): readonly string[];
67
+ /**
68
+ * Remember the paths a discovery run found.
69
+ *
70
+ * The known paths are merged rather than replaced, newest first, so a cache
71
+ * that a concurrent process also wrote does not lose the other process's
72
+ * entry. A cache that cannot be written is not an error: the paths stay in
73
+ * memory for this process and are rediscovered next time.
74
+ * @param cacheDir - the plugin's cache directory.
75
+ * @param discovered - the paths a discovery run found.
76
+ * @returns the merged set, in the order it should be tried.
77
+ */
78
+ export declare function rememberPaths(cacheDir: string | undefined, discovered: readonly string[]): readonly string[];
@@ -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,11 +44,34 @@ 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.
43
56
  */
44
57
  export declare function parseGoDocument(markdown: string): GoDoc;
58
+ /**
59
+ * How the document is found and where the answer is kept.
60
+ *
61
+ * The discovery is injectable so a test can decide what the provider's
62
+ * repository says without reaching the network — which is the only way this
63
+ * module can be exercised offline at all.
64
+ */
65
+ export interface GoDocOptions {
66
+ /** Where the discovered document paths are remembered. */
67
+ readonly cacheDir?: string | undefined;
68
+ /**
69
+ * Ask the provider's repository where its document is.
70
+ *
71
+ * Defaults to the real listing. A test injects a fixed answer.
72
+ */
73
+ readonly discover?: (signal?: AbortSignal) => Promise<readonly string[]>;
74
+ }
45
75
  /**
46
76
  * Fetch and parse the documentation, asking every source concurrently.
47
77
  *
@@ -51,9 +81,16 @@ export declare function parseGoDocument(markdown: string): GoDoc;
51
81
  * quota badge and the protocol fallback. `GO_DOC_URLS` order still decides:
52
82
  * the repository file is examined before its cache mirror, and a source that
53
83
  * fails is reported with its own reason.
84
+ *
85
+ * The known paths are tried first, then the paths a previous discovery
86
+ * remembered, and only then is the provider's repository asked where its
87
+ * document is. That order is what keeps the cost at one request in the common
88
+ * case while still surviving the provider moving the file: a path that is
89
+ * there is used, and a path that is not is replaced by a discovered one.
54
90
  * @param signal - caller cancellation; it reaches every source's fetch and
55
91
  * aborts all of them, so a cancelled discovery stops waiting on the network.
92
+ * @param options - where the discovered paths are remembered.
56
93
  * @returns the parsed document from the first source that carried one.
57
94
  * @throws {LlmError} `DOCUMENT_UNAVAILABLE` when every source failed.
58
95
  */
59
- export declare function fetchGoDocument(signal?: AbortSignal): Promise<GoDoc>;
96
+ export declare function fetchGoDocument(signal?: AbortSignal, options?: GoDocOptions): Promise<GoDoc>;
@@ -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. */
@@ -80,6 +106,14 @@ export interface CatalogOptions {
80
106
  * cancellation, forwarded to every documentation request.
81
107
  */
82
108
  readonly readDocument?: (signal?: AbortSignal) => Promise<GoDoc>;
109
+ /**
110
+ * Where the discovered document paths are remembered.
111
+ *
112
+ * Absent means the plugin cannot write a cache, and the paths are then only
113
+ * known to the running process. That costs one discovery per start, and
114
+ * nothing else.
115
+ */
116
+ readonly cacheDir?: string;
83
117
  }
84
118
  /**
85
119
  * One gateway's catalog. Requests reuse a cached snapshot; explicit discovery
@@ -94,6 +128,8 @@ export declare class OpencodeGoCatalog {
94
128
  private lastMetadata;
95
129
  /** The last parsed documentation; retained across failed fetches. */
96
130
  private document;
131
+ /** Ids that already forced a revalidation, with the time they did. */
132
+ private readonly probedAt;
97
133
  constructor(options: CatalogOptions);
98
134
  /**
99
135
  * The cached snapshot, refreshed when it is older than the configured TTL.
@@ -116,6 +152,19 @@ export declare class OpencodeGoCatalog {
116
152
  * the id but this build cannot configure it, naming the reason.
117
153
  */
118
154
  forModel(id: string, signal?: AbortSignal): Promise<CatalogSnapshot>;
155
+ /**
156
+ * Whether this id may revalidate the catalog again.
157
+ *
158
+ * The revalidation is what makes a newly published model usable without a
159
+ * restart, so it cannot simply be dropped. It is also the most expensive
160
+ * thing this route does — the metadata document is megabytes — and the host
161
+ * resolves a model on every list and on every new session. An id that never
162
+ * resolves would therefore pay that cost forever, which is how a plugin
163
+ * makes a host slow down over time rather than start slow.
164
+ * @param id - the model id that was not found.
165
+ * @returns true when the id may force a revalidation now.
166
+ */
167
+ private mayProbe;
119
168
  /**
120
169
  * Revalidate every source, reusing the metadata document when it is unchanged.
121
170
  * @param signal - caller cancellation, forwarded to the documentation read.
@@ -123,7 +172,14 @@ export declare class OpencodeGoCatalog {
123
172
  private build;
124
173
  /** Merge the listing with the metadata document and the installed entries. */
125
174
  private assemble;
126
- /** The document allowances as a snapshot field, absent until one fetch lands. */
175
+ /**
176
+ * The document allowances as a snapshot field, absent until one fetch lands.
177
+ *
178
+ * A document that carries no allowance table leaves the field absent rather
179
+ * than empty, so the frozen seed in `go-limits.ts` answers instead. An empty
180
+ * map would look like "the provider states no allowance for any model", and
181
+ * that is a different claim from "this build has no live allowances".
182
+ */
127
183
  private documentQuotas;
128
184
  /**
129
185
  * 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.46",
3
+ "version": "0.1.48",
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",