dsh-opencode-go 0.1.14 → 0.1.16

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,7 +50,7 @@ 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";
53
+ import { randomUUID as randomUUID2 } from "node:crypto";
54
54
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
55
55
  import {
56
56
  LlmAdapter,
@@ -273,15 +273,15 @@ var api = llm;
273
273
  function projectRequestImages(messages, policy) {
274
274
  if (api.requiredImageOffload !== void 0 && api.projectOffloadedImages !== void 0) {
275
275
  if (!policy.exact) return messages;
276
- if (policy.maxBytes !== void 0) {
276
+ if (policy.maxBytes !== void 0 || policy.maxImages !== void 0) {
277
277
  const offloadImages = api.requiredImageOffload(
278
278
  messages,
279
- { representation: "base64", maxBytes: policy.maxBytes },
279
+ { representation: "base64", maxBytes: policy.maxBytes, maxImages: policy.maxImages, countQuantum: 1 },
280
280
  (block) => policy.byteLength(block.attachment)
281
281
  );
282
282
  if (offloadImages > 0) {
283
283
  throw new llm.LlmError(
284
- `pi-ai request images exceed the ${policy.maxBytes}-byte base64 bound; ${offloadImages} more oldest occurrence(s) must be offloaded.`,
284
+ `pi-ai request images exceed the configured image count or base64 payload bound; ${offloadImages} more oldest occurrence(s) must be offloaded.`,
285
285
  api.IMAGE_OFFLOAD_REQUIRED_CODE ?? "IMAGE_OFFLOAD_REQUIRED",
286
286
  { offloadImages }
287
287
  );
@@ -295,6 +295,8 @@ function projectRequestImages(messages, policy) {
295
295
  return api.offloadRequestImagesWithPolicy(messages, {
296
296
  representation: "base64",
297
297
  byteQuantum: 1,
298
+ countQuantum: 1,
299
+ maxImages: policy.maxImages,
298
300
  maxBytes: policy.maxBytes,
299
301
  byteLength: policy.byteLength,
300
302
  placeholder: policy.placeholder
@@ -478,7 +480,7 @@ function toPiContext(options, images, onReplayDegrade) {
478
480
  return images === void 0 ? textOnlyContext(options, onReplayDegrade) : toPiContextWithImages(options, images, onReplayDegrade);
479
481
  }
480
482
  async function toPiContextWithImages(options, images, onReplayDegrade) {
481
- const { attachments, resolveImageAccess, maxRequestImageBytes } = images;
483
+ const { attachments, resolveImageAccess, maxImages, maxRequestImageBytes } = images;
482
484
  const requestImagePolicy = images.requestImagePolicy ?? {
483
485
  maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
484
486
  maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES
@@ -486,6 +488,7 @@ async function toPiContextWithImages(options, images, onReplayDegrade) {
486
488
  assertSupportedHistory(options.messages);
487
489
  const split = splitSystemPrompt(options);
488
490
  const projection = {
491
+ maxImages,
489
492
  maxBytes: maxRequestImageBytes,
490
493
  placeholder: (ref) => offloadedImageText(ref, resolveImageAccess(ref))
491
494
  };
@@ -713,6 +716,13 @@ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.l
713
716
  import { attributionHeaders, LlmError as LlmError5 } from "@deepseek-ai/dsh-llm";
714
717
 
715
718
  // src/models-contract.ts
719
+ var INPUT_MODALITIES = ["text", "image", "audio", "video", "pdf"];
720
+ function normalizeInputModalities(value) {
721
+ if (!Array.isArray(value)) return void 0;
722
+ const declared = new Set(value.filter((item) => typeof item === "string").map((item) => item.toLowerCase()));
723
+ const ordered = INPUT_MODALITIES.filter((modality) => declared.has(modality));
724
+ return ordered.length === 0 ? void 0 : ordered;
725
+ }
716
726
  function isModelEnabled(model, modelVisibility) {
717
727
  if (model.configurationMissing) return false;
718
728
  const enabled = modelVisibility && Object.hasOwn(modelVisibility, model.id) ? modelVisibility[model.id] : void 0;
@@ -744,6 +754,8 @@ function parseGoModels(value) {
744
754
  if (typeof row.deprecated === "boolean") model.deprecated = row.deprecated;
745
755
  if (typeof row.configurationMissing === "boolean") model.configurationMissing = row.configurationMissing;
746
756
  if (validReleaseDate(row.releaseDate)) model.releaseDate = row.releaseDate;
757
+ const modalities = normalizeInputModalities(row.inputModalities);
758
+ if (modalities !== void 0) model.inputModalities = modalities;
747
759
  return model;
748
760
  });
749
761
  }
@@ -849,9 +861,11 @@ function readModelMetadata(body, baseURL, builtin) {
849
861
  const details = /* @__PURE__ */ new Map();
850
862
  for (const [id, value] of Object.entries(entries)) {
851
863
  const data = record(value);
864
+ const inputModalities = normalizeInputModalities(record(data.modalities).input);
852
865
  details.set(id, {
853
866
  deprecated: data.status === "deprecated",
854
- ...validReleaseDate(data.release_date) ? { releaseDate: data.release_date } : {}
867
+ ...validReleaseDate(data.release_date) ? { releaseDate: data.release_date } : {},
868
+ ...inputModalities === void 0 ? {} : { inputModalities }
855
869
  });
856
870
  try {
857
871
  const metadata = record(value);
@@ -993,6 +1007,55 @@ async function readJsonResponse(response, maxBytes) {
993
1007
  }
994
1008
  }
995
1009
 
1010
+ // src/metadata-cache.ts
1011
+ import { randomUUID } from "node:crypto";
1012
+ import { createReadStream } from "node:fs";
1013
+ import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
1014
+ import { homedir } from "node:os";
1015
+ import { dirname, join, resolve } from "node:path";
1016
+ var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
1017
+ var CACHE_MAX_BYTES = MODEL_METADATA_MAX_BYTES + 4096;
1018
+ function metadataCachePath() {
1019
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
1020
+ return resolve(home, "cache", "dsh-opencode-go", "models.dev.api.json");
1021
+ }
1022
+ function metadataETag(value) {
1023
+ return typeof value === "string" && value.length <= 1024 && /^(W\/)?"[\x21\x23-\x7e\x80-\xff]*"$/.test(value) ? value : void 0;
1024
+ }
1025
+ async function readMetadataCache(path) {
1026
+ try {
1027
+ const info = await stat(path);
1028
+ if (!info.isFile() || info.size > CACHE_MAX_BYTES) return void 0;
1029
+ const chunks = [];
1030
+ let size = 0;
1031
+ for await (const chunk of createReadStream(path)) {
1032
+ const bytes = chunk;
1033
+ size += bytes.length;
1034
+ if (size > CACHE_MAX_BYTES) return void 0;
1035
+ chunks.push(bytes);
1036
+ }
1037
+ const value = JSON.parse(Buffer.concat(chunks, size).toString("utf8"));
1038
+ 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;
1039
+ return { body: value.body, savedAtMs: value.savedAtMs, etag: metadataETag(value.etag) };
1040
+ } catch {
1041
+ return void 0;
1042
+ }
1043
+ }
1044
+ async function writeMetadataCache(path, value) {
1045
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
1046
+ try {
1047
+ const content = JSON.stringify({ version: 1, sourceURL: MODEL_METADATA_URL, ...value });
1048
+ if (Buffer.byteLength(content) > CACHE_MAX_BYTES) return;
1049
+ await mkdir(dirname(path), { recursive: true });
1050
+ await writeFile(temporary, content, { flag: "wx", mode: 384 });
1051
+ await rename(temporary, path);
1052
+ } catch {
1053
+ } finally {
1054
+ await rm(temporary, { force: true }).catch(() => {
1055
+ });
1056
+ }
1057
+ }
1058
+
996
1059
  // src/catalog.ts
997
1060
  var PROVIDER_ID = "opencode-go";
998
1061
  var DISPLAY_NAME = "OpenCode Go";
@@ -1001,19 +1064,22 @@ var MODELS_FETCH_TIMEOUT_MS = 1e4;
1001
1064
  var METADATA_FETCH_TIMEOUT_MS = 3e4;
1002
1065
  var STALE_WHILE_REVALIDATE_MS = 5 * 6e4;
1003
1066
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
1004
- var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
1005
1067
  var RETRY_MIN_MS = 5e3;
1006
1068
  var RETRY_MAX_MS = 6e4;
1069
+ async function settled(promise) {
1070
+ const [result] = await Promise.allSettled([promise]);
1071
+ return result;
1072
+ }
1007
1073
  function waitForSnapshot(pending, signal) {
1008
1074
  if (signal === void 0) return pending;
1009
- return new Promise((resolve, reject) => {
1075
+ return new Promise((resolve2, reject) => {
1010
1076
  const abort = () => {
1011
1077
  signal.removeEventListener("abort", abort);
1012
1078
  reject(new LlmError5("opencode-go catalog request aborted by caller", "ABORTED", { cause: signal.reason }));
1013
1079
  };
1014
1080
  pending.then((value) => {
1015
1081
  signal.removeEventListener("abort", abort);
1016
- resolve(value);
1082
+ resolve2(value);
1017
1083
  }, (error) => {
1018
1084
  signal.removeEventListener("abort", abort);
1019
1085
  reject(error);
@@ -1084,15 +1150,22 @@ function buildProvider(baseURL, models) {
1084
1150
  });
1085
1151
  }
1086
1152
  var OpencodeGoCatalog = class {
1087
- constructor(baseURL, refreshMs, onFallback, onOmitted) {
1153
+ constructor(baseURL, refreshMs, onFallback, onOmitted, onRefresh = () => {
1154
+ }) {
1088
1155
  this.baseURL = baseURL;
1089
1156
  this.refreshMs = refreshMs;
1090
1157
  this.onFallback = onFallback;
1091
1158
  this.onOmitted = onOmitted;
1159
+ this.onRefresh = onRefresh;
1092
1160
  }
1093
1161
  served;
1094
1162
  pending;
1163
+ /** A cold runtime read can use disk metadata while the shared online refresh runs. */
1164
+ cachedPending;
1165
+ initialized;
1166
+ cachePath = metadataCachePath();
1095
1167
  metadata;
1168
+ metadataBody;
1096
1169
  metadataETag;
1097
1170
  metadataUpdatedAtMs;
1098
1171
  failures = 0;
@@ -1101,19 +1174,63 @@ var OpencodeGoCatalog = class {
1101
1174
  if (signal?.aborted) {
1102
1175
  return Promise.reject(new LlmError5("opencode-go catalog request aborted by caller", "ABORTED", { cause: signal.reason }));
1103
1176
  }
1104
- if (!force && this.served !== void 0 && Date.now() < this.refreshAtMs) {
1105
- return Promise.resolve(this.served);
1177
+ return waitForSnapshot(this.served === void 0 ? this.readSnapshot(force) : this.currentSnapshot(force), signal);
1178
+ }
1179
+ async restoreMetadata() {
1180
+ const saved = await readMetadataCache(this.cachePath);
1181
+ if (saved === void 0) return;
1182
+ try {
1183
+ this.metadata = readModelMetadata(saved.body, this.baseURL, builtinModels(this.baseURL));
1184
+ this.metadataBody = saved.body;
1185
+ this.metadataETag = saved.etag;
1186
+ this.metadataUpdatedAtMs = saved.savedAtMs;
1187
+ } catch {
1188
+ }
1189
+ }
1190
+ async readSnapshot(force) {
1191
+ await (this.initialized ??= this.restoreMetadata());
1192
+ return this.currentSnapshot(force);
1193
+ }
1194
+ currentSnapshot(force) {
1195
+ if (!force && this.served !== void 0 && Date.now() < this.refreshAtMs) return Promise.resolve(this.served);
1196
+ if (this.pending === void 0) this.startRefresh(force);
1197
+ return !force && this.cachedPending !== void 0 ? this.cachedPending : this.pending;
1198
+ }
1199
+ startRefresh(force) {
1200
+ const builtin = builtinModels(this.baseURL);
1201
+ const listing = settled(fetchLiveModelIds(this.baseURL).then((ids) => ({ ids, updatedAtMs: Date.now() })));
1202
+ const restored = this.metadata;
1203
+ if (!force && this.served === void 0 && restored !== void 0) {
1204
+ const status = { metadataLive: false, metadataUpdatedAtMs: this.metadataUpdatedAtMs };
1205
+ this.cachedPending = listing.then((result) => {
1206
+ const snapshot = this.build(builtin, result, restored, status);
1207
+ this.served = snapshot;
1208
+ return snapshot;
1209
+ });
1106
1210
  }
1107
- this.pending ??= this.build().then((snapshot) => {
1211
+ const notify = this.cachedPending !== void 0 || !force && this.served !== void 0;
1212
+ this.pending = Promise.all([listing, settled(this.refreshMetadata(builtin)), this.cachedPending]).then(([listing2, result]) => this.build(
1213
+ builtin,
1214
+ listing2,
1215
+ result.status === "fulfilled" ? result.value : this.metadata,
1216
+ {
1217
+ metadataLive: result.status === "fulfilled",
1218
+ ...this.metadataUpdatedAtMs === void 0 ? {} : { metadataUpdatedAtMs: this.metadataUpdatedAtMs },
1219
+ ...result.status === "rejected" ? { metadataFailure: result.reason } : {}
1220
+ }
1221
+ )).then((snapshot) => {
1108
1222
  this.served = snapshot;
1109
1223
  this.failures = snapshot.live && snapshot.metadataLive ? 0 : Math.min(this.failures + 1, 5);
1110
1224
  const lifetime = this.failures === 0 ? this.refreshMs : Math.min(this.refreshMs, RETRY_MIN_MS * 2 ** (this.failures - 1), RETRY_MAX_MS);
1111
1225
  this.refreshAtMs = snapshot.fetchedAtMs + lifetime;
1226
+ if (notify) this.onRefresh();
1112
1227
  return snapshot;
1113
1228
  }).finally(() => {
1114
1229
  this.pending = void 0;
1230
+ this.cachedPending = void 0;
1231
+ });
1232
+ void this.pending.catch(() => {
1115
1233
  });
1116
- return waitForSnapshot(this.pending, signal);
1117
1234
  }
1118
1235
  /** Conditional HTTP requests save bandwidth while still checking for updated metadata. */
1119
1236
  async refreshMetadata(builtin) {
@@ -1128,6 +1245,8 @@ var OpencodeGoCatalog = class {
1128
1245
  }, MODEL_METADATA_MAX_BYTES);
1129
1246
  if (response.status === 304 && this.metadata !== void 0) {
1130
1247
  this.metadataUpdatedAtMs = Date.now();
1248
+ this.metadataETag = metadataETag(response.headers.get("etag")) ?? this.metadataETag;
1249
+ await this.persistMetadata();
1131
1250
  return this.metadata;
1132
1251
  }
1133
1252
  if (!response.ok) throw new LlmError5(`${MODEL_METADATA_URL} answered HTTP ${response.status}`, "DISCOVERY_FAILED");
@@ -1138,26 +1257,24 @@ var OpencodeGoCatalog = class {
1138
1257
  throw new LlmError5(`${MODEL_METADATA_URL} returned invalid model configuration`, "DISCOVERY_FAILED", { cause: error });
1139
1258
  }
1140
1259
  this.metadata = metadata;
1141
- this.metadataETag = response.headers.get("etag") ?? void 0;
1260
+ this.metadataBody = body;
1261
+ this.metadataETag = metadataETag(response.headers.get("etag"));
1142
1262
  this.metadataUpdatedAtMs = Date.now();
1263
+ await this.persistMetadata();
1143
1264
  return metadata;
1144
1265
  }
1266
+ async persistMetadata() {
1267
+ await writeMetadataCache(this.cachePath, {
1268
+ body: this.metadataBody,
1269
+ etag: this.metadataETag,
1270
+ savedAtMs: this.metadataUpdatedAtMs
1271
+ });
1272
+ }
1145
1273
  /** Gateway ids decide membership; online metadata decides how to call each model. */
1146
- async build() {
1147
- const builtin = builtinModels(this.baseURL);
1148
- const [listing, metadataResult] = await Promise.allSettled([
1149
- fetchLiveModelIds(this.baseURL).then((ids) => ({ ids, updatedAtMs: Date.now() })),
1150
- this.refreshMetadata(builtin)
1151
- ]);
1152
- const metadata = metadataResult.status === "fulfilled" ? metadataResult.value : this.metadata;
1153
- const metadataStatus = {
1154
- metadataLive: metadataResult.status === "fulfilled",
1155
- ...this.metadataUpdatedAtMs === void 0 ? {} : { metadataUpdatedAtMs: this.metadataUpdatedAtMs },
1156
- ...metadataResult.status === "rejected" ? { metadataFailure: metadataResult.reason } : {}
1157
- };
1274
+ build(builtin, listing, metadata, metadataStatus) {
1158
1275
  const known = new Map([...builtin, ...this.served?.models ?? [], ...metadata?.models ?? []]);
1159
- if (metadataResult.status === "rejected") {
1160
- this.onFallback({ url: MODEL_METADATA_URL, error: metadataResult.reason, kept: known.size });
1276
+ if (metadataStatus.metadataFailure !== void 0) {
1277
+ this.onFallback({ url: MODEL_METADATA_URL, error: metadataStatus.metadataFailure, kept: known.size });
1161
1278
  }
1162
1279
  if (listing.status === "rejected") {
1163
1280
  const models2 = this.served?.models ?? /* @__PURE__ */ new Map();
@@ -1185,7 +1302,9 @@ var OpencodeGoCatalog = class {
1185
1302
  models.set(id, model);
1186
1303
  }
1187
1304
  }
1188
- if (unavailable.size > 0) this.onOmitted([...unavailable.keys()]);
1305
+ if (unavailable.size > 0 && (metadataStatus.metadataLive || metadataStatus.metadataFailure !== void 0)) {
1306
+ this.onOmitted([...unavailable.keys()]);
1307
+ }
1189
1308
  return {
1190
1309
  details: new Map(listing.value.ids.map((id) => [id, metadata?.details.get(id) ?? {}])),
1191
1310
  models,
@@ -1207,7 +1326,9 @@ var OpencodeGoCatalog = class {
1207
1326
  return cached;
1208
1327
  }
1209
1328
  let snapshot = await this.snapshot(false, signal);
1210
- if (!snapshot.models.has(id) && snapshot === cached) snapshot = await this.snapshot(true, signal);
1329
+ if (!snapshot.models.has(id) && (snapshot === cached || this.cachedPending !== void 0)) {
1330
+ snapshot = await this.snapshot(true, signal);
1331
+ }
1211
1332
  if (signal?.aborted) throw new LlmError5("opencode-go catalog request aborted by caller", "ABORTED", { cause: signal.reason });
1212
1333
  if (snapshot.unavailable.has(id)) {
1213
1334
  throw new LlmError5(
@@ -1286,6 +1407,7 @@ var fields = {
1286
1407
  baseURL: z.string().default(DEFAULT_BASE_URL),
1287
1408
  refreshMinutes: z.number().step(1).min(1).max(7 * 24 * 60).default(DEFAULT_REFRESH_MINUTES),
1288
1409
  streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
1410
+ maxImages: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
1289
1411
  // The image defaults are the generic pi-ai adapter's: one normalized
1290
1412
  // request image fits the budget, and fifteen of them fit the payload cap.
1291
1413
  maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
@@ -1332,7 +1454,7 @@ function withModelLimit(model, limits) {
1332
1454
  };
1333
1455
  }
1334
1456
  function opencodeSessionValue(sessionId) {
1335
- return sessionId !== void 0 && sessionId.length > 0 ? sessionId : randomUUID();
1457
+ return sessionId !== void 0 && sessionId.length > 0 ? sessionId : randomUUID2();
1336
1458
  }
1337
1459
  var OpencodeGoAdapter = class extends LlmAdapter {
1338
1460
  constructor(options) {
@@ -1356,19 +1478,20 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1356
1478
  catalogOf(config) {
1357
1479
  const key = `${config.baseURL}|${String(config.refreshMinutes)}`;
1358
1480
  if (this.catalogCache?.key !== key) {
1359
- this.catalogCache = {
1360
- key,
1361
- catalog: new OpencodeGoCatalog(
1362
- assertBaseURL(config.baseURL),
1363
- config.refreshMinutes * 6e4,
1364
- /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1365
- this.options.onFallback ?? (() => {
1366
- }),
1367
- /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1368
- this.options.onOmitted ?? (() => {
1369
- })
1370
- )
1371
- };
1481
+ const catalog = new OpencodeGoCatalog(
1482
+ assertBaseURL(config.baseURL),
1483
+ config.refreshMinutes * 6e4,
1484
+ /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1485
+ this.options.onFallback ?? (() => {
1486
+ }),
1487
+ /* v8 ignore next -- the plugin always passes both observers; the defaults exist for direct construction */
1488
+ this.options.onOmitted ?? (() => {
1489
+ }),
1490
+ () => {
1491
+ if (this.catalogCache?.catalog === catalog) this.options.onCatalogRefresh?.();
1492
+ }
1493
+ );
1494
+ this.catalogCache = { key, catalog };
1372
1495
  }
1373
1496
  return this.catalogCache.catalog;
1374
1497
  }
@@ -1476,6 +1599,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1476
1599
  imageRequest = {
1477
1600
  attachments: store,
1478
1601
  resolveImageAccess: (ref) => access.resolveImageAccess(store, ref),
1602
+ maxImages: config.maxImages ?? void 0,
1479
1603
  maxRequestImageBytes: config.maxRequestImageBytes,
1480
1604
  requestImagePolicy: {
1481
1605
  maxPixels: config.requestImagePixelBudget,
@@ -1543,7 +1667,7 @@ var OpencodeGoAdapter = class extends LlmAdapter {
1543
1667
  };
1544
1668
 
1545
1669
  // src/usage.ts
1546
- import { randomUUID as randomUUID2 } from "node:crypto";
1670
+ import { randomUUID as randomUUID3 } from "node:crypto";
1547
1671
  import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
1548
1672
  import { attributionHeaders as attributionHeaders3 } from "@deepseek-ai/dsh-llm";
1549
1673
 
@@ -1615,7 +1739,7 @@ var GoUsageService = class extends TypertRemoteService {
1615
1739
  });
1616
1740
  }
1617
1741
  if (this.identity?.baseURL !== baseURL || this.identity.key !== key) {
1618
- this.identity = { baseURL, key, source: randomUUID2() };
1742
+ this.identity = { baseURL, key, source: randomUUID3() };
1619
1743
  }
1620
1744
  const { source } = this.identity;
1621
1745
  const endpoint = diagnosticURL(`${baseURL}/usage`);
@@ -1721,6 +1845,7 @@ function apply(ctx, raw) {
1721
1845
  ctx.logger.warn(`llm-opencode-go: gateway models awaiting usable online metadata: ${ids.join(", ")}`);
1722
1846
  }
1723
1847
  };
1848
+ let registration;
1724
1849
  const adapter = new OpencodeGoAdapter({
1725
1850
  config: () => current(),
1726
1851
  resolveApiKey,
@@ -1734,11 +1859,13 @@ function apply(ctx, raw) {
1734
1859
  },
1735
1860
  onFallback: logger.fallback,
1736
1861
  onOmitted: logger.omitted,
1862
+ onCatalogRefresh: () => {
1863
+ registration?.replace([PROVIDER_ID]);
1864
+ },
1737
1865
  onReplayDegrade: (reason) => {
1738
1866
  ctx.logger.warn(`llm-opencode-go: unusable replay state on assistant history; sending provider-neutral content (${reason})`);
1739
1867
  }
1740
1868
  });
1741
- let registration;
1742
1869
  ctx.plugin(GoModelsService, {
1743
1870
  catalog: () => adapter.catalogOf(current()),
1744
1871
  onRefresh: () => {
@@ -1791,6 +1918,7 @@ function apply(ctx, raw) {
1791
1918
  });
1792
1919
  ctx.effect(() => () => {
1793
1920
  registration?.();
1921
+ registration = void 0;
1794
1922
  undiscover();
1795
1923
  });
1796
1924
  ctx.inject(["settings"], (settingsCtx) => {
@@ -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,
@@ -30,9 +30,15 @@ export declare class OpencodeGoCatalog {
30
30
  private readonly onFallback;
31
31
  /** Kept for API compatibility; now reports unconfigured ids rather than hiding them. */
32
32
  private readonly onOmitted;
33
+ private readonly onRefresh;
33
34
  private served;
34
35
  private pending;
36
+ /** A cold runtime read can use disk metadata while the shared online refresh runs. */
37
+ private cachedPending;
38
+ private initialized;
39
+ private readonly cachePath;
35
40
  private metadata;
41
+ private metadataBody;
36
42
  private metadataETag;
37
43
  private metadataUpdatedAtMs;
38
44
  private failures;
@@ -43,10 +49,15 @@ export declare class OpencodeGoCatalog {
43
49
  kept: number;
44
50
  }) => void,
45
51
  /** Kept for API compatibility; now reports unconfigured ids rather than hiding them. */
46
- onOmitted: (ids: readonly string[]) => void);
52
+ onOmitted: (ids: readonly string[]) => void, onRefresh?: () => void);
47
53
  snapshot(force?: boolean, signal?: AbortSignal): Promise<CatalogSnapshot>;
54
+ private restoreMetadata;
55
+ private readSnapshot;
56
+ private currentSnapshot;
57
+ private startRefresh;
48
58
  /** Conditional HTTP requests save bandwidth while still checking for updated metadata. */
49
59
  private refreshMetadata;
60
+ private persistMetadata;
50
61
  /** Gateway ids decide membership; online metadata decides how to call each model. */
51
62
  private build;
52
63
  /** New or previously unconfigured ids get a fresh lookup even during the runtime TTL. */
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The OpenCode Go settings section. It leads with the one value a user has to
3
- * supply — the API key, stored write-only through the credentials domain — and
4
- * the models the gateway currently serves, then keeps the credential
5
- * reference, the endpoint, and the adapter tuning fields in the
6
- * `llm-opencode-go` namespace behind a collapsed disclosure.
2
+ * The OpenCode Go settings page. Three cards carry the page's whole order — the
3
+ * connection (the switch that routes the provider plus the API key it
4
+ * authenticates with), the models the gateway currently serves and their
5
+ * capacities, and the adapter tuning fields behind a collapsed disclosure at the
6
+ * foot. The credential reference, the endpoint, and the tuning knobs all live in
7
+ * the `llm-opencode-go` namespace, so every card writes the same document.
7
8
  */
8
9
  import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots';
9
10
  import type { OpencodeGoSectionFace } from './section-controller.ts';
@@ -19,6 +19,7 @@ export declare const en: {
19
19
  usageRefreshing: string;
20
20
  nav: string;
21
21
  title: string;
22
+ titleLabel: string;
22
23
  intro: string;
23
24
  starProject: string;
24
25
  starProjectTitle: string;
@@ -56,6 +57,15 @@ export declare const en: {
56
57
  limitsOutput: string;
57
58
  capacityDefault: string;
58
59
  capacityMissing: string;
60
+ modalityLabel: string;
61
+ modalityText: string;
62
+ modalityImage: string;
63
+ modalityAudio: string;
64
+ modalityVideo: string;
65
+ modalityPdf: string;
66
+ modalityUnknown: string;
67
+ modalitiesSource: string;
68
+ modalitiesForwarding: string;
59
69
  visibilityHint: string;
60
70
  modelVisibleLabel: string;
61
71
  configurationMissing: string;
@@ -67,7 +77,6 @@ export declare const en: {
67
77
  deprecatedHint: string;
68
78
  newHint: string;
69
79
  newBadge: string;
70
- releasedOn: string;
71
80
  releaseSource: string;
72
81
  modelDetails: string;
73
82
  filterLabel: string;
@@ -81,6 +90,7 @@ export declare const en: {
81
90
  limitsEmpty: string;
82
91
  limitsContextLabel: string;
83
92
  limitsOutputLabel: string;
93
+ advancedSummary: string;
84
94
  advancedLabel: string;
85
95
  advancedHint: string;
86
96
  apiKeyEnvLabel: string;
@@ -91,6 +101,8 @@ export declare const en: {
91
101
  refreshMinutesHint: string;
92
102
  streamIdleTimeoutMsLabel: string;
93
103
  streamIdleTimeoutMsHint: string;
104
+ maxImagesLabel: string;
105
+ maxImagesHint: string;
94
106
  maxRequestImageBytesLabel: string;
95
107
  maxRequestImageBytesHint: string;
96
108
  requestImagePixelBudgetLabel: string;
@@ -30,6 +30,8 @@ export interface OpencodeGoSettings {
30
30
  refreshMinutes?: number;
31
31
  /** Largest idle gap between stream events, in milliseconds. */
32
32
  streamIdleTimeoutMs?: number;
33
+ /** Optional retained image occurrence cap per request. */
34
+ maxImages?: number | null;
33
35
  /** Accumulated base64 image payload bound for one request. */
34
36
  maxRequestImageBytes?: number;
35
37
  /** Total-pixel budget for one request image. */
@@ -94,6 +96,8 @@ export interface OpencodeGoSectionState extends FormShell {
94
96
  refreshMinutes: FieldState;
95
97
  /** Largest idle gap between stream events, in milliseconds. */
96
98
  streamIdleTimeoutMs: FieldState;
99
+ /** Optional retained image occurrence cap; blank inherits the base configuration. */
100
+ maxImages: FieldState;
97
101
  /** Accumulated base64 image payload bound for one request. */
98
102
  maxRequestImageBytes: FieldState;
99
103
  /** Total-pixel budget for one request image. */
@@ -88,12 +88,13 @@ export interface FormActions {
88
88
  discard: () => void;
89
89
  }
90
90
  /**
91
- * A whole-number field. An empty draft clears the field; any other draft that
92
- * is not a finite number blocks the save.
91
+ * A numeric field. An empty draft clears the field; other drafts must be
92
+ * finite numbers and satisfy the optional constraint before saving.
93
93
  * @param field - field name inside the namespace section.
94
+ * @param validate - additional numeric constraint, if needed.
94
95
  * @returns the field's conversion spec.
95
96
  */
96
- export declare function numberField(field: string): FieldSpec;
97
+ export declare function numberField(field: string, validate?: (value: number) => boolean): FieldSpec;
97
98
  /**
98
99
  * A two-state field. Unlike the text fields, the draft is the boolean itself
99
100
  * rather than text: the control that renders it reports the state the user
@@ -48,6 +48,8 @@ export interface OpencodeGoConfig {
48
48
  refreshMinutes: number;
49
49
  /** Largest idle gap between stream events before the request fails. */
50
50
  streamIdleTimeoutMs: number;
51
+ /** Optional retained image occurrence cap per request; omission or null leaves the count unlimited. */
52
+ maxImages?: number | null;
51
53
  /** Request-level bound on base64-encoded image payload, in bytes. */
52
54
  maxRequestImageBytes: number;
53
55
  /** Total-pixel budget for one request image. */
@@ -12,6 +12,8 @@ export interface PiImageRequestContext {
12
12
  attachments: AttachmentStore;
13
13
  /** Resolve current tool access separately from deterministic request-image versions. */
14
14
  resolveImageAccess: ImageAttachmentAccessResolver;
15
+ /** Maximum retained image occurrences across request history; omission leaves the count unlimited. */
16
+ maxImages?: number;
15
17
  /** Request-level bound on the base64-encoded payload of retained images; omission leaves the bound unchecked. */
16
18
  maxRequestImageBytes?: number;
17
19
  /** Route pixel and raw encoded-byte budgets. */
@@ -39,7 +41,7 @@ export declare function toPiContext(options: GenerateOptions, images?: undefined
39
41
  * Tool result names are recovered from preceding assistant tool calls. On DSH
40
42
  * 0.1.5, oldest images over the request budget become transient placeholders.
41
43
  * On newer hosts, occurrences the surface marks offloaded become placeholders; when the
42
- * retained occurrences' exact base64 payload still exceeds
44
+ * retained occurrences exceed `maxImages` or their exact base64 payload exceeds
43
45
  * `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` naming
44
46
  * how many more oldest occurrences must be offloaded.
45
47
  * @param options - the harness request; `options.system`, else a leading `system` message, maps to pi-ai's single `systemPrompt` slot.
@@ -1,6 +1,7 @@
1
1
  import type { Message } from '@deepseek-ai/dsh-llm';
2
2
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
3
3
  interface ImageProjectionPolicy {
4
+ maxImages?: number;
4
5
  maxBytes?: number;
5
6
  byteLength: (ref: ImageAttachmentRef) => number;
6
7
  placeholder: (ref: ImageAttachmentRef) => string;
@@ -0,0 +1,12 @@
1
+ export declare const MODEL_METADATA_MAX_BYTES: number;
2
+ export interface CachedMetadata {
3
+ readonly body: unknown;
4
+ readonly etag?: string;
5
+ /** Last successful download or HTTP 304 verification. */
6
+ readonly savedAtMs: number;
7
+ }
8
+ export declare function metadataCachePath(): string;
9
+ /** Only persist validators that can safely become an HTTP request header. */
10
+ export declare function metadataETag(value: unknown): string | undefined;
11
+ export declare function readMetadataCache(path: string): Promise<CachedMetadata | undefined>;
12
+ export declare function writeMetadataCache(path: string, value: CachedMetadata): Promise<void>;
@@ -2,9 +2,11 @@
2
2
  import type { Api, Model } from '@earendil-works/pi-ai';
3
3
  import { type GoModel } from './models-contract.ts';
4
4
  export declare const MODEL_METADATA_URL = "https://models.dev/api.json";
5
+ /** Lifecycle and capability data the online catalog adds to a gateway listing. */
6
+ export type ModelDetails = Pick<GoModel, 'deprecated' | 'releaseDate' | 'inputModalities'>;
5
7
  export interface ModelMetadata {
6
8
  readonly models: ReadonlyMap<string, Model<Api>>;
7
- readonly details: ReadonlyMap<string, Pick<GoModel, 'deprecated' | 'releaseDate'>>;
9
+ readonly details: ReadonlyMap<string, ModelDetails>;
8
10
  readonly errors: ReadonlyMap<string, string>;
9
11
  }
10
12
  /** Anthropic's SDK appends /v1/messages; the OpenAI SDKs append paths below /v1. */