@route_33/opencode 0.4.3 → 0.4.4

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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +47 -119
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -24,7 +24,7 @@ The installer patches both configs: the server plugin into `opencode.json` and t
24
24
  }
25
25
  ```
26
26
 
27
- This plugin never writes your `opencode.json` or `opencode.jsonc`: opencode has no TUI-plugin auto-discovery, so without a `tui.json` entry the wizard simply never appears — the server plugin works regardless. The server plugin may write its own `.opencode/route33-generated.json` sidecar to track catalog-generated entries.
27
+ This plugin never writes your `opencode.json` or `opencode.jsonc`: opencode has no TUI-plugin auto-discovery, so without a `tui.json` entry the wizard simply never appears — the server plugin works regardless. The only files the plugin ever writes inside your repo are an optional `.opencode/route33.key` (git-ignored automatically) and your own `.opencode/route33-overrides.json` (yours to commit or ignore).
28
28
 
29
29
  ## Auth
30
30
 
@@ -41,7 +41,7 @@ The catalog is discovered dynamically. The plugin calls `GET {baseURL}/models` w
41
41
  3. If the live fetch fails, return the stale cache.
42
42
  4. If there is no cache and no live response, preserve existing generated entries and inject no replacement models.
43
43
 
44
- The plugin does not embed or serve a static catalog. Catalog cache envelopes are versioned; cache files from older plugin versions are ignored after an upgrade and repopulated from the gateway.
44
+ The plugin does not embed or serve a static catalog. The on-disk cache is keyed by gateway URL **and** API key, so the same key pointed at a different gateway (`ROUTE33_BASE_URL`, e.g. a local cluster) never shares a cache with production. Catalog cache envelopes are versioned; cache files from older plugin versions are ignored after an upgrade and repopulated from the gateway.
45
45
 
46
46
  If discovery yields no catalog, the plugin logs exactly one warning:
47
47
 
@@ -66,7 +66,7 @@ The normative broadcast contract is [docs/gateway-limits-variants-broadcast.md](
66
66
 
67
67
  ## Managed model entries
68
68
 
69
- Gateway-discovered `provider.route_33.models` entries are managed and regenerated at startup. On the first 0.4.2 run, existing route_33 model entries are adopted as generated; entries no longer served by the gateway are pruned and a manifest is saved at `.opencode/route33-generated.json`. Later runs only remove keys listed in that manifest, then replace them with the current broadcast.
69
+ Gateway-discovered `provider.route_33.models` entries are fully managed: every startup rebuilds the map from the current broadcast, then deep-merges your overrides on top. The ownership rule is the whole design — *in the broadcast (or in your overrides) ⇒ kept; anything else ⇒ dropped and logged* — so retired gateway models disappear automatically and no bookkeeping file is needed. (Versions ≤0.4.3 kept a `.opencode/route33-generated.json` ledger for this; the plugin deletes that obsolete file on first run.)
70
70
 
71
71
  Keep customizations in `.opencode/route33-overrides.json`, because the installed OpenCode `ProviderConfig` does not allow unknown provider-level keys such as `modelOverrides`. Overrides deep-merge onto matching generated entries and can add custom models:
72
72
 
@@ -79,7 +79,7 @@ Keep customizations in `.opencode/route33-overrides.json`, because the installed
79
79
  }
80
80
  ```
81
81
 
82
- If the gateway fetch fails with no usable cache, regeneration is skipped: existing entries are never pruned. Sidecar I/O failures also preserve the previous add-only behavior for that run.
82
+ If the gateway fetch fails with no usable cache, regeneration is skipped entirely: existing entries are never pruned on zero information.
83
83
 
84
84
  `ROUTE33_ENRICH_CAPS` has been removed; capability backfill from `models.dev` is now always on.
85
85
 
package/dist/index.js CHANGED
@@ -376,8 +376,9 @@ var memory = /* @__PURE__ */ new Map();
376
376
 
377
377
  // src/catalog/catalog-cache/hash-key.ts
378
378
  import { createHash } from "crypto";
379
- function hashKey(key) {
380
- return createHash("sha256").update(key).digest("hex").slice(0, 12);
379
+ function hashKey(baseURL, key) {
380
+ return createHash("sha256").update(`${baseURL}
381
+ ${key}`).digest("hex").slice(0, 12);
381
382
  }
382
383
 
383
384
  // src/catalog/catalog-cache/read-disk.ts
@@ -422,8 +423,8 @@ function isFresh(record, now, ttl) {
422
423
  return now - record.fetchedAt <= ttl;
423
424
  }
424
425
  function readFreshCatalog(opts) {
425
- const { key, env = process.env, now = Date.now() } = opts;
426
- const keyHash = hashKey(key);
426
+ const { baseURL, key, env = process.env, now = Date.now() } = opts;
427
+ const keyHash = hashKey(baseURL, key);
427
428
  const ttl = catalogTtlMs(env);
428
429
  const inMemory = memory.get(keyHash);
429
430
  if (inMemory && isFresh(inMemory, now, ttl)) return inMemory.models;
@@ -437,16 +438,16 @@ function readFreshCatalog(opts) {
437
438
 
438
439
  // src/catalog/catalog-cache/read-stale-catalog.ts
439
440
  function readStaleCatalog(opts) {
440
- const { key, env = process.env } = opts;
441
- const keyHash = hashKey(key);
441
+ const { baseURL, key, env = process.env } = opts;
442
+ const keyHash = hashKey(baseURL, key);
442
443
  return (memory.get(keyHash) ?? readDisk(keyHash, env))?.models;
443
444
  }
444
445
 
445
446
  // src/catalog/catalog-cache/write-catalog.ts
446
447
  import * as fs4 from "fs";
447
448
  function writeCatalog(opts) {
448
- const { key, models, env = process.env, now = Date.now() } = opts;
449
- const keyHash = hashKey(key);
449
+ const { baseURL, key, models, env = process.env, now = Date.now() } = opts;
450
+ const keyHash = hashKey(baseURL, key);
450
451
  const record = { fetchedAt: now, keyHash, models };
451
452
  memory.set(keyHash, record);
452
453
  try {
@@ -696,7 +697,7 @@ async function runLadder(opts) {
696
697
  if (env[ENV_DISCOVERY] === "0")
697
698
  return noneResult(`discovery disabled via ${ENV_DISCOVERY}=0`, true);
698
699
  if (!key) return noneResult("no API key resolved", false);
699
- const fresh = readFreshCatalog({ key, env, now });
700
+ const fresh = readFreshCatalog({ baseURL, key, env, now });
700
701
  if (fresh) {
701
702
  logger.info(
702
703
  `catalog source: cache (${Object.keys(fresh).length} model(s))`
@@ -707,7 +708,7 @@ async function runLadder(opts) {
707
708
  const live = await fetchCatalog({ baseURL, key, env, fetchImpl });
708
709
  if (Object.keys(live).length === 0)
709
710
  throw new Error("gateway returned an empty catalog");
710
- writeCatalog({ key, models: live, env, now });
711
+ writeCatalog({ baseURL, key, models: live, env, now });
711
712
  logger.info(`catalog source: live (${Object.keys(live).length} model(s))`);
712
713
  return { models: live, source: "live" };
713
714
  } catch (error) {
@@ -715,7 +716,7 @@ async function runLadder(opts) {
715
716
  `live catalog fetch failed \u2014 trying stale cache: ${error instanceof Error ? error.message : String(error)}`
716
717
  );
717
718
  }
718
- const stale = readStaleCatalog({ key, env });
719
+ const stale = readStaleCatalog({ baseURL, key, env });
719
720
  if (stale) {
720
721
  logger.info(
721
722
  `catalog source: stale-cache (${Object.keys(stale).length} model(s))`
@@ -1061,102 +1062,36 @@ function mergeConfig(cfg, blocks) {
1061
1062
  }
1062
1063
  }
1063
1064
 
1064
- // src/config/merge-models.ts
1065
- function cloneEntry(entry) {
1066
- return { ...entry };
1067
- }
1068
- function cloneModels(patch) {
1069
- const models = {};
1070
- for (const [key, entry] of Object.entries(patch.models)) {
1071
- models[key] = cloneEntry(entry);
1072
- }
1073
- return models;
1074
- }
1075
- function fillModelEntry(existing, patchEntry) {
1076
- for (const [field, value] of Object.entries(patchEntry)) {
1077
- if (value === void 0) continue;
1078
- if (field === "limit" || existing[field] === void 0)
1079
- existing[field] = value;
1080
- }
1081
- }
1082
- function mergeModels(target, patch) {
1083
- if (target.models === void 0) {
1084
- target.models = cloneModels(patch);
1085
- return;
1086
- }
1087
- const models = target.models;
1088
- for (const [key, patchEntry] of Object.entries(patch.models)) {
1089
- const existing = models[key];
1090
- if (existing === void 0) {
1091
- models[key] = cloneEntry(patchEntry);
1092
- } else {
1093
- fillModelEntry(existing, cloneEntry(patchEntry));
1094
- }
1095
- }
1096
- }
1097
-
1098
- // src/config/generation-manifest.ts
1065
+ // src/config/read-model-overrides.ts
1099
1066
  import * as fs6 from "fs";
1100
1067
  import * as path7 from "path";
1101
1068
  var SIDECAR_DIR = ".opencode";
1102
- var MANIFEST_FILE = "route33-generated.json";
1103
1069
  var OVERRIDES_FILE = "route33-overrides.json";
1104
1070
  function isRecord7(value) {
1105
1071
  return typeof value === "object" && value !== null && !Array.isArray(value);
1106
1072
  }
1107
- function generatedPath(root) {
1108
- return path7.join(root, SIDECAR_DIR, MANIFEST_FILE);
1109
- }
1110
- function overridesPath(root) {
1111
- return path7.join(root, SIDECAR_DIR, OVERRIDES_FILE);
1112
- }
1113
- function parseManifest(value) {
1114
- if (!isRecord7(value) || value.version !== 1 || !Array.isArray(value.generatedKeys) || typeof value.lastBroadcastHash !== "string") return void 0;
1115
- if (!value.generatedKeys.every((key) => typeof key === "string")) return void 0;
1116
- return { version: 1, generatedKeys: value.generatedKeys, lastBroadcastHash: value.lastBroadcastHash };
1117
- }
1118
1073
  function parseOverrides(value) {
1119
1074
  if (!isRecord7(value) || !isRecord7(value.models) || !Object.values(value.models).every(isRecord7)) return void 0;
1120
1075
  const models = {};
1121
1076
  for (const [key, entry] of Object.entries(value.models)) if (isRecord7(entry)) models[key] = entry;
1122
1077
  return models;
1123
1078
  }
1124
- function readJson(file) {
1125
- try {
1126
- if (!fs6.existsSync(file)) return void 0;
1127
- const parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
1128
- return parsed;
1129
- } catch (error) {
1130
- logger.info(`route_33: unable to read generated-model sidecar: ${error instanceof Error ? error.message : String(error)}`);
1131
- return void 0;
1132
- }
1133
- }
1134
- function readGenerationManifest(root) {
1135
- const file = generatedPath(root);
1136
- const manifest = parseManifest(readJson(file));
1137
- if (manifest === void 0 && fs6.existsSync(file)) logger.info("route_33: generated-model manifest is malformed; treating this run as first-run regeneration");
1138
- return manifest;
1139
- }
1140
1079
  function readModelOverrides(root) {
1141
- const file = overridesPath(root);
1142
- const overrides = parseOverrides(readJson(file));
1143
- if (overrides === void 0 && fs6.existsSync(file)) logger.info("route_33: model overrides sidecar is malformed; ignoring it for this run");
1144
- return overrides ?? {};
1145
- }
1146
- function writeGenerationManifest(root, manifest) {
1080
+ const file = path7.join(root, SIDECAR_DIR, OVERRIDES_FILE);
1081
+ let parsed;
1147
1082
  try {
1148
- fs6.mkdirSync(path7.join(root, SIDECAR_DIR), { recursive: true });
1149
- fs6.writeFileSync(generatedPath(root), `${JSON.stringify(manifest, null, 2)}
1150
- `);
1151
- return true;
1083
+ if (!fs6.existsSync(file)) return {};
1084
+ parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
1152
1085
  } catch (error) {
1153
- logger.info(`route_33: unable to write generated-model manifest; using add-only behavior this run: ${error instanceof Error ? error.message : String(error)}`);
1154
- return false;
1086
+ logger.info(`route_33: unable to read model overrides sidecar: ${error instanceof Error ? error.message : String(error)}`);
1087
+ return {};
1155
1088
  }
1089
+ const overrides = parseOverrides(parsed);
1090
+ if (overrides === void 0) logger.info("route_33: model overrides sidecar is malformed; ignoring it for this run");
1091
+ return overrides ?? {};
1156
1092
  }
1157
1093
 
1158
1094
  // src/config/regenerate-models.ts
1159
- import { createHash as createHash2 } from "crypto";
1160
1095
  function isRecord8(value) {
1161
1096
  return typeof value === "object" && value !== null && !Array.isArray(value);
1162
1097
  }
@@ -1165,49 +1100,46 @@ function clone(value) {
1165
1100
  if (!isRecord8(value)) return value;
1166
1101
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
1167
1102
  }
1168
- function cloneEntry2(entry) {
1103
+ function cloneEntry(entry) {
1169
1104
  const copy = {};
1170
1105
  for (const [key, value] of Object.entries(entry)) copy[key] = clone(value);
1171
1106
  return copy;
1172
1107
  }
1173
1108
  function mergeEntry(base, override) {
1174
- const result = cloneEntry2(base);
1109
+ const result = cloneEntry(base);
1175
1110
  for (const [key, value] of Object.entries(override)) {
1176
1111
  const existing = result[key];
1177
1112
  result[key] = isRecord8(existing) && isRecord8(value) ? mergeEntry(existing, value) : clone(value);
1178
1113
  }
1179
1114
  return result;
1180
1115
  }
1181
- function stableJson(value) {
1182
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
1183
- if (!isRecord8(value)) return JSON.stringify(value);
1184
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
1185
- }
1186
- function broadcastHash(models) {
1187
- return createHash2("sha256").update(stableJson(models)).digest("hex");
1188
- }
1189
1116
  function regenerateModels(input) {
1190
1117
  if (input.source === "none" && input.failedLiveFetch === true) {
1191
1118
  const models2 = {};
1192
1119
  for (const [key, entry] of Object.entries(input.currentModels ?? {}))
1193
- if (entry !== void 0) models2[key] = cloneEntry2(entry);
1120
+ if (entry !== void 0) models2[key] = cloneEntry(entry);
1194
1121
  return { models: models2, prunedKeys: [], skipped: true };
1195
1122
  }
1196
- const current = input.currentModels ?? {};
1197
- const generatedKeys = input.previousManifest?.generatedKeys ?? Object.keys(current);
1198
1123
  const models = {};
1199
- for (const [key, entry] of Object.entries(current)) {
1200
- if (!generatedKeys.includes(key) && entry !== void 0) models[key] = cloneEntry2(entry);
1201
- }
1202
- for (const [key, entry] of Object.entries(input.freshModels)) models[key] = cloneEntry2(entry);
1124
+ for (const [key, entry] of Object.entries(input.freshModels)) models[key] = cloneEntry(entry);
1203
1125
  for (const [key, override] of Object.entries(input.overrides ?? {}))
1204
1126
  models[key] = mergeEntry(models[key] ?? {}, override);
1205
- const freshKeys = Object.keys(input.freshModels).sort();
1206
- return {
1207
- models,
1208
- manifest: { version: 1, generatedKeys: freshKeys, lastBroadcastHash: broadcastHash(input.freshModels) },
1209
- prunedKeys: generatedKeys.filter((key) => input.freshModels[key] === void 0).sort()
1210
- };
1127
+ const prunedKeys = Object.keys(input.currentModels ?? {}).filter((key) => input.freshModels[key] === void 0 && input.overrides?.[key] === void 0).sort();
1128
+ return { models, prunedKeys };
1129
+ }
1130
+
1131
+ // src/config/remove-legacy-manifest.ts
1132
+ import * as fs7 from "fs";
1133
+ import * as path8 from "path";
1134
+ function removeLegacyManifest(root) {
1135
+ const file = path8.join(root, ".opencode", "route33-generated.json");
1136
+ try {
1137
+ if (!fs7.existsSync(file)) return;
1138
+ fs7.rmSync(file);
1139
+ logger.info("route_33: removed obsolete route33-generated.json (regeneration is stateless since 0.4.4)");
1140
+ } catch (error) {
1141
+ logger.info(`route_33: unable to remove legacy route33-generated.json: ${error instanceof Error ? error.message : String(error)}`);
1142
+ }
1211
1143
  }
1212
1144
 
1213
1145
  // src/config/collected-ref.ts
@@ -1437,6 +1369,7 @@ function createConfigHook(options = {}) {
1437
1369
  let key;
1438
1370
  let catalogAvailable = false;
1439
1371
  try {
1372
+ removeLegacyManifest(cwd);
1440
1373
  key = resolveKey(cfg, env, cwd);
1441
1374
  const baseURL = resolveBaseUrl(cfg, env, cwd);
1442
1375
  const catalog = await getCatalog({
@@ -1466,21 +1399,16 @@ function createConfigHook(options = {}) {
1466
1399
  const regeneration = regenerateModels({
1467
1400
  currentModels: provider.models,
1468
1401
  freshModels: blocks.route_33.models,
1469
- previousManifest: readGenerationManifest(cwd),
1470
1402
  overrides: readModelOverrides(cwd),
1471
1403
  source: catalog.source,
1472
1404
  failedLiveFetch: catalog.liveFetchFailed
1473
1405
  });
1474
1406
  if (regeneration.skipped) {
1475
- logger.info("route_33: catalog fetch failed without a cache; preserving existing managed model entries");
1407
+ logger.info("route_33: catalog fetch failed without a cache; preserving existing model entries");
1476
1408
  } else {
1477
- if (regeneration.manifest !== void 0 && !writeGenerationManifest(cwd, regeneration.manifest)) {
1478
- mergeModels(provider, blocks.route_33);
1479
- } else {
1480
- provider.models = regeneration.models;
1481
- }
1482
- for (const key2 of regeneration.prunedKeys)
1483
- logger.info(`route_33: removed stale model entry '${key2}' (no longer in the gateway catalog); move custom entries to route_33 modelOverrides \u2014 see README`);
1409
+ provider.models = regeneration.models;
1410
+ for (const dropped of regeneration.prunedKeys)
1411
+ logger.info(`route_33: dropped model entry '${dropped}' (not in the gateway catalog); custom models belong in .opencode/route33-overrides.json \u2014 see README`);
1484
1412
  }
1485
1413
  }
1486
1414
  } catch (error) {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@route_33/opencode",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
- "description": "opencode provider plugin for the route33.ai AI gateway — key-scoped model discovery, cost/limit enrichment, auth, and the key-setup wizard.",
6
+ "description": "opencode provider plugin for the route33.ai AI gateway \u2014 key-scoped model discovery, cost/limit enrichment, auth, and the key-setup wizard.",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/Cronos-AI-Lab/harness-bench.git",