@route_33/opencode 0.4.2 → 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 +6 -4
  2. package/dist/index.js +52 -129
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -24,12 +24,14 @@ 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
 
31
31
  Run `opencode auth login` and choose `route_33`, or set the `ROUTE33_API_KEY` environment variable. Without a key, the TUI wizard offers to save one: per-project inside a git workspace, global otherwise. A globally stored key applies only outside git workspaces — inside a workspace, keys resolve from the environment, the workspace config, or `.opencode/route33.key` (per-project keys are the metering boundary). Skipping the project-key wizard snoozes it for 7 days — "Skip" for that workspace only, "Skip for all workspaces" everywhere (both live in opencode's TUI kv store; remove `project-key-prompt.dismissed-at*` from `~/.local/state/opencode/kv.json` to re-arm sooner). `ROUTE33_BASE_URL` overrides the default gateway URL; `ROUTE33_TUI_PROMPT=0` disables the wizard.
32
32
 
33
+ Your key is never written into the resolved provider configuration: authentication is attached per request at fetch time, so `opencode debug config` output contains no credential material.
34
+
33
35
  ## Model catalog
34
36
 
35
37
  The catalog is discovered dynamically. The plugin calls `GET {baseURL}/models` with your resolved API key, caches the response on disk, and injects only the models that key can see. The refresh ladder is:
@@ -39,7 +41,7 @@ The catalog is discovered dynamically. The plugin calls `GET {baseURL}/models` w
39
41
  3. If the live fetch fails, return the stale cache.
40
42
  4. If there is no cache and no live response, preserve existing generated entries and inject no replacement models.
41
43
 
42
- 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.
43
45
 
44
46
  If discovery yields no catalog, the plugin logs exactly one warning:
45
47
 
@@ -64,7 +66,7 @@ The normative broadcast contract is [docs/gateway-limits-variants-broadcast.md](
64
66
 
65
67
  ## Managed model entries
66
68
 
67
- 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.)
68
70
 
69
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:
70
72
 
@@ -77,7 +79,7 @@ Keep customizations in `.opencode/route33-overrides.json`, because the installed
77
79
  }
78
80
  ```
79
81
 
80
- 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.
81
83
 
82
84
  `ROUTE33_ENRICH_CAPS` has been removed; capability backfill from `models.dev` is now always on.
83
85
 
package/dist/index.js CHANGED
@@ -289,16 +289,12 @@ function buildOpenAiChatCompatFetch(fetchImpl) {
289
289
  }
290
290
 
291
291
  // src/config/build-options.ts
292
- function buildOptions(baseURL, apiKey, resolveKeyNow = () => void 0) {
292
+ function buildOptions(baseURL, resolveKeyNow = () => void 0) {
293
293
  const options = { baseURL };
294
294
  options.fetch = buildLiveAuthFetch(
295
295
  resolveKeyNow,
296
296
  buildOpenAiChatCompatFetch()
297
297
  );
298
- if (apiKey !== void 0) {
299
- options.apiKey = apiKey;
300
- options.headers = { Authorization: `Bearer ${apiKey}` };
301
- }
302
298
  return options;
303
299
  }
304
300
 
@@ -350,7 +346,7 @@ function vendorOf(slug) {
350
346
  return slug.slice(0, slug.indexOf("/"));
351
347
  }
352
348
  function buildProviderBlocks(enriched, options) {
353
- const { apiKey, baseURL } = options;
349
+ const { baseURL } = options;
354
350
  const resolveKeyNow = options.resolveKeyNow ?? (() => void 0);
355
351
  const route33Models = {};
356
352
  for (const slug of Object.keys(enriched).sort()) {
@@ -369,7 +365,7 @@ function buildProviderBlocks(enriched, options) {
369
365
  route_33: {
370
366
  npm: "@ai-sdk/openai-compatible",
371
367
  name: PROVIDER_DISPLAY_NAME,
372
- options: buildOptions(baseURL, apiKey, resolveKeyNow),
368
+ options: buildOptions(baseURL, resolveKeyNow),
373
369
  models: route33Models
374
370
  }
375
371
  };
@@ -380,8 +376,9 @@ var memory = /* @__PURE__ */ new Map();
380
376
 
381
377
  // src/catalog/catalog-cache/hash-key.ts
382
378
  import { createHash } from "crypto";
383
- function hashKey(key) {
384
- 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);
385
382
  }
386
383
 
387
384
  // src/catalog/catalog-cache/read-disk.ts
@@ -426,8 +423,8 @@ function isFresh(record, now, ttl) {
426
423
  return now - record.fetchedAt <= ttl;
427
424
  }
428
425
  function readFreshCatalog(opts) {
429
- const { key, env = process.env, now = Date.now() } = opts;
430
- const keyHash = hashKey(key);
426
+ const { baseURL, key, env = process.env, now = Date.now() } = opts;
427
+ const keyHash = hashKey(baseURL, key);
431
428
  const ttl = catalogTtlMs(env);
432
429
  const inMemory = memory.get(keyHash);
433
430
  if (inMemory && isFresh(inMemory, now, ttl)) return inMemory.models;
@@ -441,16 +438,16 @@ function readFreshCatalog(opts) {
441
438
 
442
439
  // src/catalog/catalog-cache/read-stale-catalog.ts
443
440
  function readStaleCatalog(opts) {
444
- const { key, env = process.env } = opts;
445
- const keyHash = hashKey(key);
441
+ const { baseURL, key, env = process.env } = opts;
442
+ const keyHash = hashKey(baseURL, key);
446
443
  return (memory.get(keyHash) ?? readDisk(keyHash, env))?.models;
447
444
  }
448
445
 
449
446
  // src/catalog/catalog-cache/write-catalog.ts
450
447
  import * as fs4 from "fs";
451
448
  function writeCatalog(opts) {
452
- const { key, models, env = process.env, now = Date.now() } = opts;
453
- const keyHash = hashKey(key);
449
+ const { baseURL, key, models, env = process.env, now = Date.now() } = opts;
450
+ const keyHash = hashKey(baseURL, key);
454
451
  const record = { fetchedAt: now, keyHash, models };
455
452
  memory.set(keyHash, record);
456
453
  try {
@@ -700,7 +697,7 @@ async function runLadder(opts) {
700
697
  if (env[ENV_DISCOVERY] === "0")
701
698
  return noneResult(`discovery disabled via ${ENV_DISCOVERY}=0`, true);
702
699
  if (!key) return noneResult("no API key resolved", false);
703
- const fresh = readFreshCatalog({ key, env, now });
700
+ const fresh = readFreshCatalog({ baseURL, key, env, now });
704
701
  if (fresh) {
705
702
  logger.info(
706
703
  `catalog source: cache (${Object.keys(fresh).length} model(s))`
@@ -711,7 +708,7 @@ async function runLadder(opts) {
711
708
  const live = await fetchCatalog({ baseURL, key, env, fetchImpl });
712
709
  if (Object.keys(live).length === 0)
713
710
  throw new Error("gateway returned an empty catalog");
714
- writeCatalog({ key, models: live, env, now });
711
+ writeCatalog({ baseURL, key, models: live, env, now });
715
712
  logger.info(`catalog source: live (${Object.keys(live).length} model(s))`);
716
713
  return { models: live, source: "live" };
717
714
  } catch (error) {
@@ -719,7 +716,7 @@ async function runLadder(opts) {
719
716
  `live catalog fetch failed \u2014 trying stale cache: ${error instanceof Error ? error.message : String(error)}`
720
717
  );
721
718
  }
722
- const stale = readStaleCatalog({ key, env });
719
+ const stale = readStaleCatalog({ baseURL, key, env });
723
720
  if (stale) {
724
721
  logger.info(
725
722
  `catalog source: stale-cache (${Object.keys(stale).length} model(s))`
@@ -1065,102 +1062,36 @@ function mergeConfig(cfg, blocks) {
1065
1062
  }
1066
1063
  }
1067
1064
 
1068
- // src/config/merge-models.ts
1069
- function cloneEntry(entry) {
1070
- return { ...entry };
1071
- }
1072
- function cloneModels(patch) {
1073
- const models = {};
1074
- for (const [key, entry] of Object.entries(patch.models)) {
1075
- models[key] = cloneEntry(entry);
1076
- }
1077
- return models;
1078
- }
1079
- function fillModelEntry(existing, patchEntry) {
1080
- for (const [field, value] of Object.entries(patchEntry)) {
1081
- if (value === void 0) continue;
1082
- if (field === "limit" || existing[field] === void 0)
1083
- existing[field] = value;
1084
- }
1085
- }
1086
- function mergeModels(target, patch) {
1087
- if (target.models === void 0) {
1088
- target.models = cloneModels(patch);
1089
- return;
1090
- }
1091
- const models = target.models;
1092
- for (const [key, patchEntry] of Object.entries(patch.models)) {
1093
- const existing = models[key];
1094
- if (existing === void 0) {
1095
- models[key] = cloneEntry(patchEntry);
1096
- } else {
1097
- fillModelEntry(existing, cloneEntry(patchEntry));
1098
- }
1099
- }
1100
- }
1101
-
1102
- // src/config/generation-manifest.ts
1065
+ // src/config/read-model-overrides.ts
1103
1066
  import * as fs6 from "fs";
1104
1067
  import * as path7 from "path";
1105
1068
  var SIDECAR_DIR = ".opencode";
1106
- var MANIFEST_FILE = "route33-generated.json";
1107
1069
  var OVERRIDES_FILE = "route33-overrides.json";
1108
1070
  function isRecord7(value) {
1109
1071
  return typeof value === "object" && value !== null && !Array.isArray(value);
1110
1072
  }
1111
- function generatedPath(root) {
1112
- return path7.join(root, SIDECAR_DIR, MANIFEST_FILE);
1113
- }
1114
- function overridesPath(root) {
1115
- return path7.join(root, SIDECAR_DIR, OVERRIDES_FILE);
1116
- }
1117
- function parseManifest(value) {
1118
- if (!isRecord7(value) || value.version !== 1 || !Array.isArray(value.generatedKeys) || typeof value.lastBroadcastHash !== "string") return void 0;
1119
- if (!value.generatedKeys.every((key) => typeof key === "string")) return void 0;
1120
- return { version: 1, generatedKeys: value.generatedKeys, lastBroadcastHash: value.lastBroadcastHash };
1121
- }
1122
1073
  function parseOverrides(value) {
1123
1074
  if (!isRecord7(value) || !isRecord7(value.models) || !Object.values(value.models).every(isRecord7)) return void 0;
1124
1075
  const models = {};
1125
1076
  for (const [key, entry] of Object.entries(value.models)) if (isRecord7(entry)) models[key] = entry;
1126
1077
  return models;
1127
1078
  }
1128
- function readJson(file) {
1129
- try {
1130
- if (!fs6.existsSync(file)) return void 0;
1131
- const parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
1132
- return parsed;
1133
- } catch (error) {
1134
- logger.info(`route_33: unable to read generated-model sidecar: ${error instanceof Error ? error.message : String(error)}`);
1135
- return void 0;
1136
- }
1137
- }
1138
- function readGenerationManifest(root) {
1139
- const file = generatedPath(root);
1140
- const manifest = parseManifest(readJson(file));
1141
- if (manifest === void 0 && fs6.existsSync(file)) logger.info("route_33: generated-model manifest is malformed; treating this run as first-run regeneration");
1142
- return manifest;
1143
- }
1144
1079
  function readModelOverrides(root) {
1145
- const file = overridesPath(root);
1146
- const overrides = parseOverrides(readJson(file));
1147
- if (overrides === void 0 && fs6.existsSync(file)) logger.info("route_33: model overrides sidecar is malformed; ignoring it for this run");
1148
- return overrides ?? {};
1149
- }
1150
- function writeGenerationManifest(root, manifest) {
1080
+ const file = path7.join(root, SIDECAR_DIR, OVERRIDES_FILE);
1081
+ let parsed;
1151
1082
  try {
1152
- fs6.mkdirSync(path7.join(root, SIDECAR_DIR), { recursive: true });
1153
- fs6.writeFileSync(generatedPath(root), `${JSON.stringify(manifest, null, 2)}
1154
- `);
1155
- return true;
1083
+ if (!fs6.existsSync(file)) return {};
1084
+ parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
1156
1085
  } catch (error) {
1157
- logger.info(`route_33: unable to write generated-model manifest; using add-only behavior this run: ${error instanceof Error ? error.message : String(error)}`);
1158
- return false;
1086
+ logger.info(`route_33: unable to read model overrides sidecar: ${error instanceof Error ? error.message : String(error)}`);
1087
+ return {};
1159
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 ?? {};
1160
1092
  }
1161
1093
 
1162
1094
  // src/config/regenerate-models.ts
1163
- import { createHash as createHash2 } from "crypto";
1164
1095
  function isRecord8(value) {
1165
1096
  return typeof value === "object" && value !== null && !Array.isArray(value);
1166
1097
  }
@@ -1169,49 +1100,46 @@ function clone(value) {
1169
1100
  if (!isRecord8(value)) return value;
1170
1101
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
1171
1102
  }
1172
- function cloneEntry2(entry) {
1103
+ function cloneEntry(entry) {
1173
1104
  const copy = {};
1174
1105
  for (const [key, value] of Object.entries(entry)) copy[key] = clone(value);
1175
1106
  return copy;
1176
1107
  }
1177
1108
  function mergeEntry(base, override) {
1178
- const result = cloneEntry2(base);
1109
+ const result = cloneEntry(base);
1179
1110
  for (const [key, value] of Object.entries(override)) {
1180
1111
  const existing = result[key];
1181
1112
  result[key] = isRecord8(existing) && isRecord8(value) ? mergeEntry(existing, value) : clone(value);
1182
1113
  }
1183
1114
  return result;
1184
1115
  }
1185
- function stableJson(value) {
1186
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
1187
- if (!isRecord8(value)) return JSON.stringify(value);
1188
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
1189
- }
1190
- function broadcastHash(models) {
1191
- return createHash2("sha256").update(stableJson(models)).digest("hex");
1192
- }
1193
1116
  function regenerateModels(input) {
1194
1117
  if (input.source === "none" && input.failedLiveFetch === true) {
1195
1118
  const models2 = {};
1196
1119
  for (const [key, entry] of Object.entries(input.currentModels ?? {}))
1197
- if (entry !== void 0) models2[key] = cloneEntry2(entry);
1120
+ if (entry !== void 0) models2[key] = cloneEntry(entry);
1198
1121
  return { models: models2, prunedKeys: [], skipped: true };
1199
1122
  }
1200
- const current = input.currentModels ?? {};
1201
- const generatedKeys = input.previousManifest?.generatedKeys ?? Object.keys(current);
1202
1123
  const models = {};
1203
- for (const [key, entry] of Object.entries(current)) {
1204
- if (!generatedKeys.includes(key) && entry !== void 0) models[key] = cloneEntry2(entry);
1205
- }
1206
- 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);
1207
1125
  for (const [key, override] of Object.entries(input.overrides ?? {}))
1208
1126
  models[key] = mergeEntry(models[key] ?? {}, override);
1209
- const freshKeys = Object.keys(input.freshModels).sort();
1210
- return {
1211
- models,
1212
- manifest: { version: 1, generatedKeys: freshKeys, lastBroadcastHash: broadcastHash(input.freshModels) },
1213
- prunedKeys: generatedKeys.filter((key) => input.freshModels[key] === void 0).sort()
1214
- };
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
+ }
1215
1143
  }
1216
1144
 
1217
1145
  // src/config/collected-ref.ts
@@ -1441,6 +1369,7 @@ function createConfigHook(options = {}) {
1441
1369
  let key;
1442
1370
  let catalogAvailable = false;
1443
1371
  try {
1372
+ removeLegacyManifest(cwd);
1444
1373
  key = resolveKey(cfg, env, cwd);
1445
1374
  const baseURL = resolveBaseUrl(cfg, env, cwd);
1446
1375
  const catalog = await getCatalog({
@@ -1457,11 +1386,10 @@ function createConfigHook(options = {}) {
1457
1386
  const modelsDev = await loadModelsDev(modelsDevOptions);
1458
1387
  const enriched = enrichCatalog(catalog, modelsDev, env);
1459
1388
  const blocks = buildProviderBlocks(enriched, {
1460
- apiKey: key,
1461
1389
  baseURL,
1462
1390
  // Per-request resolution for the live-auth fetch: a prompt-entered
1463
- // key applies to the very next request, no restart. The static
1464
- // apiKey above stays the boot-time/catalog path.
1391
+ // key applies to the very next request, no restart. The block never
1392
+ // carries the key itself, so config dumps stay credential-free.
1465
1393
  resolveKeyNow: () => resolveKey(void 0, env, cwd)
1466
1394
  });
1467
1395
  mergeConfig(cfg, blocks);
@@ -1471,21 +1399,16 @@ function createConfigHook(options = {}) {
1471
1399
  const regeneration = regenerateModels({
1472
1400
  currentModels: provider.models,
1473
1401
  freshModels: blocks.route_33.models,
1474
- previousManifest: readGenerationManifest(cwd),
1475
1402
  overrides: readModelOverrides(cwd),
1476
1403
  source: catalog.source,
1477
1404
  failedLiveFetch: catalog.liveFetchFailed
1478
1405
  });
1479
1406
  if (regeneration.skipped) {
1480
- 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");
1481
1408
  } else {
1482
- if (regeneration.manifest !== void 0 && !writeGenerationManifest(cwd, regeneration.manifest)) {
1483
- mergeModels(provider, blocks.route_33);
1484
- } else {
1485
- provider.models = regeneration.models;
1486
- }
1487
- for (const key2 of regeneration.prunedKeys)
1488
- 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`);
1489
1412
  }
1490
1413
  }
1491
1414
  } catch (error) {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@route_33/opencode",
3
- "version": "0.4.2",
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",