dsh-opencode-go 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -50,8 +50,8 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
50
50
  import { LlmError as LlmError7, assertUsableApiKey, resolveImageAttachmentAccess } from "@deepseek-ai/dsh-llm";
51
51
 
52
52
  // src/adapter.ts
53
- import { randomUUID } from "node:crypto";
54
- import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
53
+ import { randomUUID as randomUUID2 } from "node:crypto";
54
+ import { getSupportedThinkingLevels, normalizeContext } from "opencode-go-pi-ai";
55
55
  import {
56
56
  LlmAdapter,
57
57
  LlmError as LlmError6,
@@ -86,13 +86,14 @@ function emptyPiUsage() {
86
86
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
87
87
  };
88
88
  }
89
- function toPiReplayState(message, requestedModel = message.model) {
89
+ function toPiReplayState(message, requestedModel = message.model, requestedProvider = message.provider) {
90
90
  const responseModel = message.api === "anthropic-messages" && message.model !== requestedModel ? message.model : message.responseModel;
91
91
  const response = {
92
92
  kind: "pi-ai",
93
93
  version: 2,
94
94
  api: message.api,
95
- provider: message.provider,
95
+ provider: requestedProvider,
96
+ ...requestedProvider === message.provider ? {} : { sdkProvider: message.provider },
96
97
  model: requestedModel,
97
98
  ...responseModel === void 0 ? {} : { responseModel },
98
99
  ...message.responseId === void 0 ? {} : { responseId: message.responseId },
@@ -137,6 +138,9 @@ function readReplayState(value) {
137
138
  for (const key of ["api", "provider", "model"]) {
138
139
  if (typeof response[key] !== "string" || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`);
139
140
  }
141
+ if (response["sdkProvider"] !== void 0 && (typeof response["sdkProvider"] !== "string" || response["sdkProvider"].length === 0)) {
142
+ return invalidReplay("sdkProvider must be a non-empty string");
143
+ }
140
144
  if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(response["stopReason"]))) {
141
145
  return invalidReplay("unknown stopReason");
142
146
  }
@@ -236,7 +240,7 @@ function replayedAssistant(message, source, rawState) {
236
240
  role: "assistant",
237
241
  content,
238
242
  api: state.response.api,
239
- provider: state.response.provider,
243
+ provider: state.response.sdkProvider ?? state.response.provider,
240
244
  // Anthropic reports aliases and fallbacks as model, unlike Completions' informational responseModel.
241
245
  model: state.response.api === "anthropic-messages" ? state.response.responseModel ?? state.response.model : state.response.model,
242
246
  ...state.response.responseModel === void 0 ? {} : { responseModel: state.response.responseModel },
@@ -551,7 +555,7 @@ async function toPiContextWithImages(options, images, onReplayDegrade) {
551
555
  // src/conversion/stream.ts
552
556
  import { brandString as brandString2 } from "@deepseek-ai/dsh-brand";
553
557
  import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError as LlmError4, QUOTA_EXCEEDED_CODE } from "@deepseek-ai/dsh-llm";
554
- import { isContextOverflow } from "@earendil-works/pi-ai";
558
+ import { isContextOverflow } from "opencode-go-pi-ai";
555
559
  function mapUsage(usage) {
556
560
  return {
557
561
  inputTokens: usage.input,
@@ -624,7 +628,7 @@ function mapStopReason(message, contextWindow) {
624
628
  }
625
629
  }
626
630
  }
627
- async function* toStreamChunks(events, contextWindow, callerSignal, requestedModel) {
631
+ async function* toStreamChunks(events, contextWindow, callerSignal, requestedModel, requestedProvider) {
628
632
  const toolIds = /* @__PURE__ */ new Map();
629
633
  for await (const event of events) {
630
634
  switch (event.type) {
@@ -686,7 +690,7 @@ async function* toStreamChunks(events, contextWindow, callerSignal, requestedMod
686
690
  yield {
687
691
  type: "finish",
688
692
  reason: mapStopReason(event.message, contextWindow),
689
- replayState: toPiReplayState(event.message, requestedModel)
693
+ replayState: toPiReplayState(event.message, requestedModel, requestedProvider)
690
694
  };
691
695
  return;
692
696
  case "error":
@@ -708,14 +712,21 @@ async function* toStreamChunks(events, contextWindow, callerSignal, requestedMod
708
712
  import { idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
709
713
 
710
714
  // src/catalog.ts
711
- import { createProvider } from "@earendil-works/pi-ai";
712
- import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
713
- import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
714
- import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
715
- import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
715
+ import { createProvider } from "opencode-go-pi-ai";
716
+ import { getBuiltinModels } from "opencode-go-pi-ai/providers/all";
717
+ import { anthropicMessagesApi } from "opencode-go-pi-ai/api/anthropic-messages.lazy";
718
+ import { openAICompletionsApi } from "opencode-go-pi-ai/api/openai-completions.lazy";
719
+ import { openAIResponsesApi } from "opencode-go-pi-ai/api/openai-responses.lazy";
716
720
  import { attributionHeaders, LlmError as LlmError5 } from "@deepseek-ai/dsh-llm";
717
721
 
718
722
  // src/models-contract.ts
723
+ var INPUT_MODALITIES = ["text", "image", "audio", "video", "pdf"];
724
+ function normalizeInputModalities(value) {
725
+ if (!Array.isArray(value)) return void 0;
726
+ const declared = new Set(value.filter((item) => typeof item === "string").map((item) => item.toLowerCase()));
727
+ const ordered = INPUT_MODALITIES.filter((modality) => declared.has(modality));
728
+ return ordered.length === 0 ? void 0 : ordered;
729
+ }
719
730
  function isModelEnabled(model, modelVisibility) {
720
731
  if (model.configurationMissing) return false;
721
732
  const enabled = modelVisibility && Object.hasOwn(modelVisibility, model.id) ? modelVisibility[model.id] : void 0;
@@ -747,6 +758,8 @@ function parseGoModels(value) {
747
758
  if (typeof row.deprecated === "boolean") model.deprecated = row.deprecated;
748
759
  if (typeof row.configurationMissing === "boolean") model.configurationMissing = row.configurationMissing;
749
760
  if (validReleaseDate(row.releaseDate)) model.releaseDate = row.releaseDate;
761
+ const modalities = normalizeInputModalities(row.inputModalities);
762
+ if (modalities !== void 0) model.inputModalities = modalities;
750
763
  return model;
751
764
  });
752
765
  }
@@ -852,9 +865,11 @@ function readModelMetadata(body, baseURL, builtin) {
852
865
  const details = /* @__PURE__ */ new Map();
853
866
  for (const [id, value] of Object.entries(entries)) {
854
867
  const data = record(value);
868
+ const inputModalities = normalizeInputModalities(record(data.modalities).input);
855
869
  details.set(id, {
856
870
  deprecated: data.status === "deprecated",
857
- ...validReleaseDate(data.release_date) ? { releaseDate: data.release_date } : {}
871
+ ...validReleaseDate(data.release_date) ? { releaseDate: data.release_date } : {},
872
+ ...inputModalities === void 0 ? {} : { inputModalities }
858
873
  });
859
874
  try {
860
875
  const metadata = record(value);
@@ -906,6 +921,9 @@ function readModelMetadata(body, baseURL, builtin) {
906
921
 
907
922
  // src/json-response.ts
908
923
  import { setTimeout as delay } from "node:timers/promises";
924
+ import { Readable, Writable } from "node:stream";
925
+ import { pipeline } from "node:stream/promises";
926
+ import { createGunzip } from "node:zlib";
909
927
  var RETRYABLE_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "EPIPE", "UND_ERR_SOCKET", "EAI_AGAIN"]);
910
928
  function errorCauses(error) {
911
929
  const causes = [];
@@ -943,10 +961,10 @@ function diagnosticURL(raw) {
943
961
  url.hash = "";
944
962
  return url.href;
945
963
  }
946
- async function fetchJsonResponse(url, init, maxBytes) {
964
+ async function fetchJsonResponse(url, init, maxBytes, encoding = "identity") {
947
965
  if ((init.method ?? "GET").toUpperCase() !== "GET") throw new TypeError("JSON requests must use GET");
948
966
  const headers = new Headers(init.headers);
949
- headers.set("accept-encoding", "identity");
967
+ headers.set("accept-encoding", encoding);
950
968
  const options = { ...init, method: "GET", headers };
951
969
  for (let attempt = 0; ; attempt += 1) {
952
970
  init.signal?.throwIfAborted();
@@ -957,7 +975,7 @@ async function fetchJsonResponse(url, init, maxBytes) {
957
975
  });
958
976
  return { response, body: void 0 };
959
977
  }
960
- return { response, body: await readJsonResponse(response, maxBytes) };
978
+ return { response, body: await readJsonResponse(response, maxBytes, { gzip: encoding === "gzip", signal: init.signal }) };
961
979
  } catch (error) {
962
980
  init.signal?.throwIfAborted();
963
981
  if (attempt > 0 || !retryableTransportFailure(error)) throw error;
@@ -987,8 +1005,33 @@ async function readBoundedBody(response, maxBytes) {
987
1005
  reader.releaseLock();
988
1006
  }
989
1007
  }
990
- async function readJsonResponse(response, maxBytes) {
991
- const bytes = await readBoundedBody(response, maxBytes);
1008
+ async function readGzipBody(bytes, maxBytes, signal) {
1009
+ const chunks = [];
1010
+ let size = 0;
1011
+ await pipeline(Readable.from([bytes]), createGunzip(), new Writable({
1012
+ write(chunk, _encoding, done) {
1013
+ if (chunk.length > maxBytes - size) {
1014
+ done(new RangeError(`Response body exceeds ${maxBytes} byte limit`));
1015
+ return;
1016
+ }
1017
+ size += chunk.length;
1018
+ chunks.push(chunk);
1019
+ done();
1020
+ }
1021
+ }), { signal: signal ?? void 0 });
1022
+ return Buffer.concat(chunks, size);
1023
+ }
1024
+ async function readJsonResponse(response, maxBytes, options = {}) {
1025
+ let bytes = await readBoundedBody(response, maxBytes);
1026
+ if (options.gzip && bytes[0] === 31 && bytes[1] === 139) {
1027
+ try {
1028
+ bytes = await readGzipBody(bytes, maxBytes, options.signal);
1029
+ } catch (error) {
1030
+ options.signal?.throwIfAborted();
1031
+ if (error instanceof RangeError) throw error;
1032
+ throw new SyntaxError("Response is not valid gzip", { cause: error });
1033
+ }
1034
+ }
992
1035
  try {
993
1036
  return JSON.parse(new TextDecoder().decode(bytes));
994
1037
  } catch (error) {
@@ -996,27 +1039,82 @@ async function readJsonResponse(response, maxBytes) {
996
1039
  }
997
1040
  }
998
1041
 
1042
+ // src/metadata-cache.ts
1043
+ import { randomUUID } from "node:crypto";
1044
+ import { createReadStream } from "node:fs";
1045
+ import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
1046
+ import { homedir } from "node:os";
1047
+ import { dirname, join, resolve } from "node:path";
1048
+ var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
1049
+ var CACHE_MAX_BYTES = MODEL_METADATA_MAX_BYTES + 4096;
1050
+ function metadataCachePath() {
1051
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
1052
+ return resolve(home, "cache", "dsh-opencode-go", "models.dev.api.json");
1053
+ }
1054
+ function metadataETag(value) {
1055
+ return typeof value === "string" && value.length <= 1024 && /^(W\/)?"[\x21\x23-\x7e\x80-\xff]*"$/.test(value) ? value : void 0;
1056
+ }
1057
+ async function readMetadataCache(path) {
1058
+ try {
1059
+ const info = await stat(path);
1060
+ if (!info.isFile() || info.size > CACHE_MAX_BYTES) return void 0;
1061
+ const chunks = [];
1062
+ let size = 0;
1063
+ for await (const chunk of createReadStream(path)) {
1064
+ const bytes = chunk;
1065
+ size += bytes.length;
1066
+ if (size > CACHE_MAX_BYTES) return void 0;
1067
+ chunks.push(bytes);
1068
+ }
1069
+ const value = JSON.parse(Buffer.concat(chunks, size).toString("utf8"));
1070
+ if (!value || value.version !== 1 || value.sourceURL !== MODEL_METADATA_URL || typeof value.savedAtMs !== "number" || !Number.isSafeInteger(value.savedAtMs) || value.savedAtMs < 0 || value.savedAtMs > Date.now() || value.etag !== void 0 && metadataETag(value.etag) === void 0) return void 0;
1071
+ return { body: value.body, savedAtMs: value.savedAtMs, etag: metadataETag(value.etag) };
1072
+ } catch {
1073
+ return void 0;
1074
+ }
1075
+ }
1076
+ async function writeMetadataCache(path, value) {
1077
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
1078
+ try {
1079
+ const content = JSON.stringify({ version: 1, sourceURL: MODEL_METADATA_URL, ...value });
1080
+ if (Buffer.byteLength(content) > CACHE_MAX_BYTES) return;
1081
+ await mkdir(dirname(path), { recursive: true });
1082
+ await writeFile(temporary, content, { flag: "wx", mode: 384 });
1083
+ await rename(temporary, path);
1084
+ } catch {
1085
+ } finally {
1086
+ await rm(temporary, { force: true }).catch(() => {
1087
+ });
1088
+ }
1089
+ }
1090
+
1091
+ // src/provider-identity.ts
1092
+ var PROVIDER_ID = "dsh-opencode-go";
1093
+ var DISPLAY_NAME = "DSH OpenCode Go";
1094
+ var SDK_PROVIDER_ID = "opencode-go";
1095
+
999
1096
  // src/catalog.ts
1000
- var PROVIDER_ID = "opencode-go";
1001
- var DISPLAY_NAME = "OpenCode Go";
1002
1097
  var DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1";
1003
1098
  var MODELS_FETCH_TIMEOUT_MS = 1e4;
1004
1099
  var METADATA_FETCH_TIMEOUT_MS = 3e4;
1005
1100
  var STALE_WHILE_REVALIDATE_MS = 5 * 6e4;
1006
1101
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
1007
- var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
1008
1102
  var RETRY_MIN_MS = 5e3;
1009
1103
  var RETRY_MAX_MS = 6e4;
1104
+ async function settled(promise) {
1105
+ const [result] = await Promise.allSettled([promise]);
1106
+ return result;
1107
+ }
1010
1108
  function waitForSnapshot(pending, signal) {
1011
1109
  if (signal === void 0) return pending;
1012
- return new Promise((resolve, reject) => {
1110
+ return new Promise((resolve2, reject) => {
1013
1111
  const abort = () => {
1014
1112
  signal.removeEventListener("abort", abort);
1015
1113
  reject(new LlmError5("opencode-go catalog request aborted by caller", "ABORTED", { cause: signal.reason }));
1016
1114
  };
1017
1115
  pending.then((value) => {
1018
1116
  signal.removeEventListener("abort", abort);
1019
- resolve(value);
1117
+ resolve2(value);
1020
1118
  }, (error) => {
1021
1119
  signal.removeEventListener("abort", abort);
1022
1120
  reject(error);
@@ -1026,9 +1124,9 @@ function waitForSnapshot(pending, signal) {
1026
1124
  });
1027
1125
  }
1028
1126
  function builtinModels(baseURL) {
1029
- return new Map(getBuiltinModels("opencode-go").map((model) => [model.id, {
1127
+ return new Map(getBuiltinModels(SDK_PROVIDER_ID).map((model) => [model.id, {
1030
1128
  ...model,
1031
- provider: PROVIDER_ID,
1129
+ provider: SDK_PROVIDER_ID,
1032
1130
  baseUrl: modelBaseURL(model.api, baseURL)
1033
1131
  }]));
1034
1132
  }
@@ -1074,7 +1172,7 @@ function harnessApiKeyAuth() {
1074
1172
  }
1075
1173
  function buildProvider(baseURL, models) {
1076
1174
  return createProvider({
1077
- id: PROVIDER_ID,
1175
+ id: SDK_PROVIDER_ID,
1078
1176
  name: DISPLAY_NAME,
1079
1177
  baseUrl: baseURL,
1080
1178
  auth: harnessApiKeyAuth(),
@@ -1087,15 +1185,22 @@ function buildProvider(baseURL, models) {
1087
1185
  });
1088
1186
  }
1089
1187
  var OpencodeGoCatalog = class {
1090
- constructor(baseURL, refreshMs, onFallback, onOmitted) {
1188
+ constructor(baseURL, refreshMs, onFallback, onOmitted, onRefresh = () => {
1189
+ }) {
1091
1190
  this.baseURL = baseURL;
1092
1191
  this.refreshMs = refreshMs;
1093
1192
  this.onFallback = onFallback;
1094
1193
  this.onOmitted = onOmitted;
1194
+ this.onRefresh = onRefresh;
1095
1195
  }
1096
1196
  served;
1097
1197
  pending;
1198
+ /** A cold runtime read can use disk metadata while the shared online refresh runs. */
1199
+ cachedPending;
1200
+ initialized;
1201
+ cachePath = metadataCachePath();
1098
1202
  metadata;
1203
+ metadataBody;
1099
1204
  metadataETag;
1100
1205
  metadataUpdatedAtMs;
1101
1206
  failures = 0;
@@ -1104,19 +1209,63 @@ var OpencodeGoCatalog = class {
1104
1209
  if (signal?.aborted) {
1105
1210
  return Promise.reject(new LlmError5("opencode-go catalog request aborted by caller", "ABORTED", { cause: signal.reason }));
1106
1211
  }
1107
- if (!force && this.served !== void 0 && Date.now() < this.refreshAtMs) {
1108
- return Promise.resolve(this.served);
1212
+ return waitForSnapshot(this.served === void 0 ? this.readSnapshot(force) : this.currentSnapshot(force), signal);
1213
+ }
1214
+ async restoreMetadata() {
1215
+ const saved = await readMetadataCache(this.cachePath);
1216
+ if (saved === void 0) return;
1217
+ try {
1218
+ this.metadata = readModelMetadata(saved.body, this.baseURL, builtinModels(this.baseURL));
1219
+ this.metadataBody = saved.body;
1220
+ this.metadataETag = saved.etag;
1221
+ this.metadataUpdatedAtMs = saved.savedAtMs;
1222
+ } catch {
1109
1223
  }
1110
- this.pending ??= this.build().then((snapshot) => {
1224
+ }
1225
+ async readSnapshot(force) {
1226
+ await (this.initialized ??= this.restoreMetadata());
1227
+ return this.currentSnapshot(force);
1228
+ }
1229
+ currentSnapshot(force) {
1230
+ if (!force && this.served !== void 0 && Date.now() < this.refreshAtMs) return Promise.resolve(this.served);
1231
+ if (this.pending === void 0) this.startRefresh(force);
1232
+ return !force && this.cachedPending !== void 0 ? this.cachedPending : this.pending;
1233
+ }
1234
+ startRefresh(force) {
1235
+ const builtin = builtinModels(this.baseURL);
1236
+ const listing = settled(fetchLiveModelIds(this.baseURL).then((ids) => ({ ids, updatedAtMs: Date.now() })));
1237
+ const restored = this.metadata;
1238
+ if (!force && this.served === void 0 && restored !== void 0) {
1239
+ const status = { metadataLive: false, metadataUpdatedAtMs: this.metadataUpdatedAtMs };
1240
+ this.cachedPending = listing.then((result) => {
1241
+ const snapshot = this.build(builtin, result, restored, status);
1242
+ this.served = snapshot;
1243
+ return snapshot;
1244
+ });
1245
+ }
1246
+ const notify = this.cachedPending !== void 0 || !force && this.served !== void 0;
1247
+ this.pending = Promise.all([listing, settled(this.refreshMetadata(builtin)), this.cachedPending]).then(([listing2, result]) => this.build(
1248
+ builtin,
1249
+ listing2,
1250
+ result.status === "fulfilled" ? result.value : this.metadata,
1251
+ {
1252
+ metadataLive: result.status === "fulfilled",
1253
+ ...this.metadataUpdatedAtMs === void 0 ? {} : { metadataUpdatedAtMs: this.metadataUpdatedAtMs },
1254
+ ...result.status === "rejected" ? { metadataFailure: result.reason } : {}
1255
+ }
1256
+ )).then((snapshot) => {
1111
1257
  this.served = snapshot;
1112
1258
  this.failures = snapshot.live && snapshot.metadataLive ? 0 : Math.min(this.failures + 1, 5);
1113
1259
  const lifetime = this.failures === 0 ? this.refreshMs : Math.min(this.refreshMs, RETRY_MIN_MS * 2 ** (this.failures - 1), RETRY_MAX_MS);
1114
1260
  this.refreshAtMs = snapshot.fetchedAtMs + lifetime;
1261
+ if (notify) this.onRefresh();
1115
1262
  return snapshot;
1116
1263
  }).finally(() => {
1117
1264
  this.pending = void 0;
1265
+ this.cachedPending = void 0;
1266
+ });
1267
+ void this.pending.catch(() => {
1118
1268
  });
1119
- return waitForSnapshot(this.pending, signal);
1120
1269
  }
1121
1270
  /** Conditional HTTP requests save bandwidth while still checking for updated metadata. */
1122
1271
  async refreshMetadata(builtin) {
@@ -1128,9 +1277,11 @@ var OpencodeGoCatalog = class {
1128
1277
  },
1129
1278
  cache: "no-cache",
1130
1279
  signal: AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS)
1131
- }, MODEL_METADATA_MAX_BYTES);
1280
+ }, MODEL_METADATA_MAX_BYTES, "gzip");
1132
1281
  if (response.status === 304 && this.metadata !== void 0) {
1133
1282
  this.metadataUpdatedAtMs = Date.now();
1283
+ this.metadataETag = metadataETag(response.headers.get("etag")) ?? this.metadataETag;
1284
+ await this.persistMetadata();
1134
1285
  return this.metadata;
1135
1286
  }
1136
1287
  if (!response.ok) throw new LlmError5(`${MODEL_METADATA_URL} answered HTTP ${response.status}`, "DISCOVERY_FAILED");
@@ -1141,26 +1292,24 @@ var OpencodeGoCatalog = class {
1141
1292
  throw new LlmError5(`${MODEL_METADATA_URL} returned invalid model configuration`, "DISCOVERY_FAILED", { cause: error });
1142
1293
  }
1143
1294
  this.metadata = metadata;
1144
- this.metadataETag = response.headers.get("etag") ?? void 0;
1295
+ this.metadataBody = body;
1296
+ this.metadataETag = metadataETag(response.headers.get("etag"));
1145
1297
  this.metadataUpdatedAtMs = Date.now();
1298
+ await this.persistMetadata();
1146
1299
  return metadata;
1147
1300
  }
1301
+ async persistMetadata() {
1302
+ await writeMetadataCache(this.cachePath, {
1303
+ body: this.metadataBody,
1304
+ etag: this.metadataETag,
1305
+ savedAtMs: this.metadataUpdatedAtMs
1306
+ });
1307
+ }
1148
1308
  /** Gateway ids decide membership; online metadata decides how to call each model. */
1149
- async build() {
1150
- const builtin = builtinModels(this.baseURL);
1151
- const [listing, metadataResult] = await Promise.allSettled([
1152
- fetchLiveModelIds(this.baseURL).then((ids) => ({ ids, updatedAtMs: Date.now() })),
1153
- this.refreshMetadata(builtin)
1154
- ]);
1155
- const metadata = metadataResult.status === "fulfilled" ? metadataResult.value : this.metadata;
1156
- const metadataStatus = {
1157
- metadataLive: metadataResult.status === "fulfilled",
1158
- ...this.metadataUpdatedAtMs === void 0 ? {} : { metadataUpdatedAtMs: this.metadataUpdatedAtMs },
1159
- ...metadataResult.status === "rejected" ? { metadataFailure: metadataResult.reason } : {}
1160
- };
1309
+ build(builtin, listing, metadata, metadataStatus) {
1161
1310
  const known = new Map([...builtin, ...this.served?.models ?? [], ...metadata?.models ?? []]);
1162
- if (metadataResult.status === "rejected") {
1163
- this.onFallback({ url: MODEL_METADATA_URL, error: metadataResult.reason, kept: known.size });
1311
+ if (metadataStatus.metadataFailure !== void 0) {
1312
+ this.onFallback({ url: MODEL_METADATA_URL, error: metadataStatus.metadataFailure, kept: known.size });
1164
1313
  }
1165
1314
  if (listing.status === "rejected") {
1166
1315
  const models2 = this.served?.models ?? /* @__PURE__ */ new Map();
@@ -1188,7 +1337,9 @@ var OpencodeGoCatalog = class {
1188
1337
  models.set(id, model);
1189
1338
  }
1190
1339
  }
1191
- if (unavailable.size > 0) this.onOmitted([...unavailable.keys()]);
1340
+ if (unavailable.size > 0 && (metadataStatus.metadataLive || metadataStatus.metadataFailure !== void 0)) {
1341
+ this.onOmitted([...unavailable.keys()]);
1342
+ }
1192
1343
  return {
1193
1344
  details: new Map(listing.value.ids.map((id) => [id, metadata?.details.get(id) ?? {}])),
1194
1345
  models,
@@ -1210,7 +1361,9 @@ var OpencodeGoCatalog = class {
1210
1361
  return cached;
1211
1362
  }
1212
1363
  let snapshot = await this.snapshot(false, signal);
1213
- if (!snapshot.models.has(id) && snapshot === cached) snapshot = await this.snapshot(true, signal);
1364
+ if (!snapshot.models.has(id) && (snapshot === cached || this.cachedPending !== void 0)) {
1365
+ snapshot = await this.snapshot(true, signal);
1366
+ }
1214
1367
  if (signal?.aborted) throw new LlmError5("opencode-go catalog request aborted by caller", "ABORTED", { cause: signal.reason });
1215
1368
  if (snapshot.unavailable.has(id)) {
1216
1369
  throw new LlmError5(
@@ -1336,7 +1489,7 @@ function withModelLimit(model, limits) {
1336
1489
  };
1337
1490
  }
1338
1491
  function opencodeSessionValue(sessionId) {
1339
- return sessionId !== void 0 && sessionId.length > 0 ? sessionId : randomUUID();
1492
+ return sessionId !== void 0 && sessionId.length > 0 ? sessionId : randomUUID2();
1340
1493
  }
1341
1494
  var OpencodeGoAdapter = class extends LlmAdapter {
1342
1495
  constructor(options) {
@@ -1360,19 +1513,20 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1360
1513
  catalogOf(config) {
1361
1514
  const key = `${config.baseURL}|${String(config.refreshMinutes)}`;
1362
1515
  if (this.catalogCache?.key !== key) {
1363
- this.catalogCache = {
1364
- key,
1365
- catalog: new OpencodeGoCatalog(
1366
- assertBaseURL(config.baseURL),
1367
- config.refreshMinutes * 6e4,
1368
- /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1369
- this.options.onFallback ?? (() => {
1370
- }),
1371
- /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1372
- this.options.onOmitted ?? (() => {
1373
- })
1374
- )
1375
- };
1516
+ const catalog = new OpencodeGoCatalog(
1517
+ assertBaseURL(config.baseURL),
1518
+ config.refreshMinutes * 6e4,
1519
+ /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1520
+ this.options.onFallback ?? (() => {
1521
+ }),
1522
+ /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1523
+ this.options.onOmitted ?? (() => {
1524
+ }),
1525
+ () => {
1526
+ if (this.catalogCache?.catalog === catalog) this.options.onCatalogRefresh?.();
1527
+ }
1528
+ );
1529
+ this.catalogCache = { key, catalog };
1376
1530
  }
1377
1531
  return this.catalogCache.catalog;
1378
1532
  }
@@ -1489,7 +1643,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1489
1643
  };
1490
1644
  }
1491
1645
  const context = imageRequest === void 0 ? toPiContext(options, void 0, this.options.onReplayDegrade) : await toPiContext({ ...options, signal: watchdog.signal }, imageRequest, this.options.onReplayDegrade);
1492
- const events = snapshot.provider.streamSimple(model, context, {
1646
+ const events = snapshot.provider.streamSimple(model, normalizeContext(context), {
1493
1647
  apiKey,
1494
1648
  ...reasoning === void 0 || reasoning === "off" ? {} : { reasoning },
1495
1649
  ...options.temperature === void 0 ? {} : { temperature: options.temperature },
@@ -1507,7 +1661,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1507
1661
  // one SDK attempt.
1508
1662
  maxRetries: 0
1509
1663
  });
1510
- const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]();
1664
+ const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id, options.provider)[Symbol.asyncIterator]();
1511
1665
  let exhausted = false;
1512
1666
  try {
1513
1667
  while (true) {
@@ -1548,7 +1702,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1548
1702
  };
1549
1703
 
1550
1704
  // src/usage.ts
1551
- import { randomUUID as randomUUID2 } from "node:crypto";
1705
+ import { randomUUID as randomUUID3 } from "node:crypto";
1552
1706
  import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
1553
1707
  import { attributionHeaders as attributionHeaders3 } from "@deepseek-ai/dsh-llm";
1554
1708
 
@@ -1620,7 +1774,7 @@ var GoUsageService = class extends TypertRemoteService {
1620
1774
  });
1621
1775
  }
1622
1776
  if (this.identity?.baseURL !== baseURL || this.identity.key !== key) {
1623
- this.identity = { baseURL, key, source: randomUUID2() };
1777
+ this.identity = { baseURL, key, source: randomUUID3() };
1624
1778
  }
1625
1779
  const { source } = this.identity;
1626
1780
  const endpoint = diagnosticURL(`${baseURL}/usage`);
@@ -1726,6 +1880,7 @@ function apply(ctx, raw) {
1726
1880
  ctx.logger.warn(`llm-opencode-go: gateway models awaiting usable online metadata: ${ids.join(", ")}`);
1727
1881
  }
1728
1882
  };
1883
+ let registration;
1729
1884
  const adapter = new OpencodeGoAdapter({
1730
1885
  config: () => current(),
1731
1886
  resolveApiKey,
@@ -1739,11 +1894,13 @@ function apply(ctx, raw) {
1739
1894
  },
1740
1895
  onFallback: logger.fallback,
1741
1896
  onOmitted: logger.omitted,
1897
+ onCatalogRefresh: () => {
1898
+ registration?.replace([PROVIDER_ID]);
1899
+ },
1742
1900
  onReplayDegrade: (reason) => {
1743
1901
  ctx.logger.warn(`llm-opencode-go: unusable replay state on assistant history; sending provider-neutral content (${reason})`);
1744
1902
  }
1745
1903
  });
1746
- let registration;
1747
1904
  ctx.plugin(GoModelsService, {
1748
1905
  catalog: () => adapter.catalogOf(current()),
1749
1906
  onRefresh: () => {
@@ -1756,6 +1913,7 @@ function apply(ctx, raw) {
1756
1913
  if (configured && current().enabled && registration === void 0) {
1757
1914
  try {
1758
1915
  registration = ctx.llm.registerAdapter([PROVIDER_ID], adapter);
1916
+ ctx.logger.info(`llm-opencode-go: route "${PROVIDER_ID}" registered as ${DISPLAY_NAME}`);
1759
1917
  } catch (error) {
1760
1918
  ctx.logger.error(`llm-opencode-go: not registering the "${PROVIDER_ID}" route (${String(error)})`);
1761
1919
  }
@@ -1796,6 +1954,7 @@ function apply(ctx, raw) {
1796
1954
  });
1797
1955
  ctx.effect(() => () => {
1798
1956
  registration?.();
1957
+ registration = void 0;
1799
1958
  undiscover();
1800
1959
  });
1801
1960
  ctx.inject(["settings"], (settingsCtx) => {
@@ -1828,7 +1987,6 @@ function apply(ctx, raw) {
1828
1987
  });
1829
1988
  syncRoute();
1830
1989
  });
1831
- ctx.logger.info(`llm-opencode-go: route "${PROVIDER_ID}" registered as ${DISPLAY_NAME}`);
1832
1990
  }
1833
1991
  export {
1834
1992
  Config,
@@ -62,6 +62,8 @@ export interface OpencodeGoAdapterOptions {
62
62
  onOmitted?: (ids: readonly string[]) => void;
63
63
  /** Observe assistant history degrading to provider-neutral conversion. */
64
64
  onReplayDegrade?: (reason: string) => void;
65
+ /** Re-read picker models after a background catalog refresh commits. */
66
+ onCatalogRefresh?: () => void;
65
67
  }
66
68
  /**
67
69
  * The single route's adapter. The catalog snapshot freezes at each operation,
@@ -1,9 +1,8 @@
1
- import type { Api, Model, Provider } from '@earendil-works/pi-ai';
1
+ import type { Api, Model, Provider } from 'opencode-go-pi-ai';
2
2
  import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm';
3
3
  import { type GoModelCatalog } from './models-contract.ts';
4
4
  import type { ModelMetadata } from './model-metadata.ts';
5
- export declare const PROVIDER_ID = "opencode-go";
6
- export declare const DISPLAY_NAME = "OpenCode Go";
5
+ export { PROVIDER_ID, DISPLAY_NAME } from './provider-identity.ts';
7
6
  export declare const DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1";
8
7
  export interface CatalogSnapshot {
9
8
  readonly details: ModelMetadata['details'];
@@ -30,9 +29,15 @@ export declare class OpencodeGoCatalog {
30
29
  private readonly onFallback;
31
30
  /** Kept for API compatibility; now reports unconfigured ids rather than hiding them. */
32
31
  private readonly onOmitted;
32
+ private readonly onRefresh;
33
33
  private served;
34
34
  private pending;
35
+ /** A cold runtime read can use disk metadata while the shared online refresh runs. */
36
+ private cachedPending;
37
+ private initialized;
38
+ private readonly cachePath;
35
39
  private metadata;
40
+ private metadataBody;
36
41
  private metadataETag;
37
42
  private metadataUpdatedAtMs;
38
43
  private failures;
@@ -43,10 +48,15 @@ export declare class OpencodeGoCatalog {
43
48
  kept: number;
44
49
  }) => void,
45
50
  /** Kept for API compatibility; now reports unconfigured ids rather than hiding them. */
46
- onOmitted: (ids: readonly string[]) => void);
51
+ onOmitted: (ids: readonly string[]) => void, onRefresh?: () => void);
47
52
  snapshot(force?: boolean, signal?: AbortSignal): Promise<CatalogSnapshot>;
53
+ private restoreMetadata;
54
+ private readSnapshot;
55
+ private currentSnapshot;
56
+ private startRefresh;
48
57
  /** Conditional HTTP requests save bandwidth while still checking for updated metadata. */
49
58
  private refreshMetadata;
59
+ private persistMetadata;
50
60
  /** Gateway ids decide membership; online metadata decides how to call each model. */
51
61
  private build;
52
62
  /** New or previously unconfigured ids get a fresh lookup even during the runtime TTL. */
@@ -1,9 +1,10 @@
1
+ import { type ReactNode } from 'react';
1
2
  import type { OpencodeGoModelLimit, OpencodeGoModelLimits, OpencodeGoModels } from './section-controller.ts';
2
3
  import type { en } from './locales.ts';
3
4
  type Translate = (key: keyof typeof en, params?: Record<string, unknown>) => string;
4
5
  export declare function hasCapacityOverride(limit: OpencodeGoModelLimit | null | undefined): boolean;
5
6
  /** Per-model switches apply immediately; capacity edits stay in the staged form. */
6
- export declare function ModelEditor({ models, draft, modelVisibility, t, locale, disabled, visibilitySaving, onEdit, onModelEnabled }: {
7
+ export declare function ModelEditor({ models, draft, modelVisibility, t, locale, disabled, visibilitySaving, filterAction, onEdit, onModelEnabled }: {
7
8
  models: OpencodeGoModels;
8
9
  draft: OpencodeGoModelLimits;
9
10
  modelVisibility: Readonly<Record<string, boolean>>;
@@ -11,6 +12,8 @@ export declare function ModelEditor({ models, draft, modelVisibility, t, locale,
11
12
  locale?: string;
12
13
  disabled: boolean;
13
14
  visibilitySaving: boolean;
15
+ /** Optional control pinned to the right of the filter row, such as a refresh. */
16
+ filterAction?: ReactNode;
14
17
  onEdit: (next: OpencodeGoModelLimits) => void;
15
18
  onModelEnabled: (id: string, enabled: boolean) => void;
16
19
  }): import("react").JSX.Element;