@route_33/opencode 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.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 config: opencode has no TUI-plugin auto-discovery, so without a `tui.json` entry the wizard simply never appears — the server plugin works regardless.
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.
28
28
 
29
29
  ## Auth
30
30
 
@@ -37,9 +37,9 @@ The catalog is discovered dynamically. The plugin calls `GET {baseURL}/models` w
37
37
  1. Return the fresh on-disk cache if it has not expired.
38
38
  2. Fetch live from the gateway and update the cache.
39
39
  3. If the live fetch fails, return the stale cache.
40
- 4. If there is no cache and no live response, inject zero models.
40
+ 4. If there is no cache and no live response, preserve existing generated entries and inject no replacement models.
41
41
 
42
- The plugin does not embed or serve a static catalog.
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.
43
43
 
44
44
  If discovery yields no catalog, the plugin logs exactly one warning:
45
45
 
@@ -55,10 +55,29 @@ Models are decorated with metadata from the route33 gateway broadcast; `models.d
55
55
 
56
56
  - **Cost**: the gateway's broadcast pricing wins; `models.dev` fills in when the broadcast carries no pricing.
57
57
  - **Limits**: the gateway's broadcast `limits` win; `models.dev` fills in when the gateway broadcasts none. Limits are omitted entirely when neither source knows them; the plugin never fabricates them.
58
+ - **Modalities**: gateway `modalities.input` / `modalities.output` are passed through to opencode. Invalid modality values and malformed modality objects are omitted per model.
59
+ - **Attachment**: gateway `attachment` wins whenever present. `models.dev` supplies it only when the gateway omits the field.
58
60
  - **Capabilities**: the broadcast `reasoning` and `tool_support` flags win (`tool_support` is mapped to `tool_call`); `models.dev` always backfills any capability flag left undefined.
59
61
  - **Variants**: R33 broadcast variants are passed verbatim (for example, `kimi-k3`'s `max` `reasoningEffort` variant).
60
62
 
61
- The normative contract for limits and variants is [docs/gateway-limits-variants-broadcast.md](docs/gateway-limits-variants-broadcast.md).
63
+ The normative broadcast contract is [docs/gateway-limits-variants-broadcast.md](docs/gateway-limits-variants-broadcast.md).
64
+
65
+ ## Managed model entries
66
+
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.
68
+
69
+ 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
+
71
+ ```json
72
+ {
73
+ "models": {
74
+ "openai/gpt-5.5": { "variants": { "fast": { "temperature": 0 } } },
75
+ "custom/internal": { "name": "Internal model" }
76
+ }
77
+ }
78
+ ```
79
+
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.
62
81
 
63
82
  `ROUTE33_ENRICH_CAPS` has been removed; capability backfill from `models.dev` is now always on.
64
83
 
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ import { Plugin } from '@opencode-ai/plugin';
6
6
  *
7
7
  * Registers the `config` hook — key-scoped model discovery, cost/limit
8
8
  * enrichment, add-only injection into the single `route_33` provider block
9
- * (per-model native-SDK routing via `provider.npm`) and strict flavour-ref
9
+ * (per-model native-SDK routing via `provider.npm`) and strict model-reference
10
10
  * validation — plus the `route_33` auth method and options loader.
11
11
  * Discovery runs once at startup; restart opencode after a key or config
12
12
  * change.
package/dist/index.js CHANGED
@@ -328,7 +328,10 @@ function toModelPatch(meta, routingSlug, forceReasoning, nativeNpm) {
328
328
  if (routingSlug !== void 0) entry.id = routingSlug;
329
329
  if (nativeNpm !== void 0) entry.provider = { npm: nativeNpm };
330
330
  const caps = meta.capabilities;
331
- if (caps.attachment !== void 0) entry.attachment = caps.attachment;
331
+ if (meta.attachment !== void 0) entry.attachment = meta.attachment;
332
+ else if (caps.attachment !== void 0) entry.attachment = caps.attachment;
333
+ if (meta.modalities !== void 0)
334
+ entry.modalities = { input: [...meta.modalities.input], output: [...meta.modalities.output] };
332
335
  if (caps.reasoning !== void 0) entry.reasoning = caps.reasoning;
333
336
  if (forceReasoning) entry.reasoning = true;
334
337
  if (caps.temperature !== void 0) entry.temperature = caps.temperature;
@@ -390,6 +393,9 @@ function cacheFilePath(keyHash, env) {
390
393
  return path5.join(resolveCacheDir(env), `catalog-${keyHash}.json`);
391
394
  }
392
395
 
396
+ // src/catalog/catalog-cache/cache-version.ts
397
+ var CATALOG_CACHE_VERSION = 2;
398
+
393
399
  // src/catalog/catalog-cache/read-disk.ts
394
400
  function isRecord3(value) {
395
401
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -399,7 +405,7 @@ function readDisk(keyHash, env) {
399
405
  const parsed = JSON.parse(
400
406
  fs3.readFileSync(cacheFilePath(keyHash, env), "utf8")
401
407
  );
402
- if (!isRecord3(parsed) || typeof parsed.fetchedAt !== "number" || !isRecord3(parsed.models))
408
+ if (!isRecord3(parsed) || parsed.version !== CATALOG_CACHE_VERSION || typeof parsed.fetchedAt !== "number" || !isRecord3(parsed.models))
403
409
  return void 0;
404
410
  return {
405
411
  fetchedAt: parsed.fetchedAt,
@@ -449,7 +455,10 @@ function writeCatalog(opts) {
449
455
  memory.set(keyHash, record);
450
456
  try {
451
457
  fs4.mkdirSync(resolveCacheDir(env), { recursive: true });
452
- fs4.writeFileSync(cacheFilePath(keyHash, env), JSON.stringify(record));
458
+ fs4.writeFileSync(
459
+ cacheFilePath(keyHash, env),
460
+ JSON.stringify({ version: CATALOG_CACHE_VERSION, ...record })
461
+ );
453
462
  } catch (error) {
454
463
  logger.warn(
455
464
  `could not persist catalog cache: ${error instanceof Error ? error.message : String(error)}`
@@ -593,6 +602,13 @@ function parseVariants(raw) {
593
602
  if (!Object.values(raw).every(isRecord5)) return void 0;
594
603
  return raw;
595
604
  }
605
+ function isModality(value) {
606
+ return value === "text" || value === "audio" || value === "image" || value === "video" || value === "pdf";
607
+ }
608
+ function parseModalities(raw) {
609
+ if (!isRecord5(raw) || !Array.isArray(raw.input) || !Array.isArray(raw.output)) return void 0;
610
+ return { input: raw.input.filter(isModality), output: raw.output.filter(isModality) };
611
+ }
596
612
  function toCatalogModels(entries, env) {
597
613
  const models = {};
598
614
  for (const entry of entries) {
@@ -610,6 +626,9 @@ function toCatalogModels(entries, env) {
610
626
  if (capabilities !== void 0) model.capabilities = capabilities;
611
627
  const variants = parseVariants(entry.variants);
612
628
  if (variants !== void 0) model.variants = variants;
629
+ const modalities = parseModalities(entry.modalities);
630
+ if (modalities !== void 0) model.modalities = modalities;
631
+ if (typeof entry.attachment === "boolean") model.attachment = entry.attachment;
613
632
  models[slug] = model;
614
633
  }
615
634
  return models;
@@ -707,7 +726,7 @@ async function runLadder(opts) {
707
726
  );
708
727
  return { models: stale, source: "stale-cache" };
709
728
  }
710
- return noneResult("live fetch failed and no cache available", false);
729
+ return { ...noneResult("live fetch failed and no cache available", false), liveFetchFailed: true };
711
730
  }
712
731
  async function getCatalog(opts) {
713
732
  try {
@@ -748,14 +767,24 @@ function resolveCapabilities(catalogModel, modelsDevLookup) {
748
767
  function enrichModel(slug, sources) {
749
768
  const modelsDevLookup = sources.modelsDev.lookup(slug);
750
769
  const catalogModel = sources.catalogModel;
770
+ const catalogPresent = sources.catalogPresent ?? catalogModel !== void 0;
751
771
  const capabilities = resolveCapabilities(catalogModel, modelsDevLookup);
752
772
  const name = catalogModel !== void 0 && catalogModel.id === slug && catalogModel.displayName !== void 0 ? catalogModel.displayName : slug;
753
773
  const meta = { name, capabilities };
774
+ if (catalogModel?.modalities !== void 0) meta.modalities = catalogModel.modalities;
775
+ const attachment = catalogModel?.attachment ?? modelsDevLookup?.attachment;
776
+ if (attachment !== void 0) meta.attachment = attachment;
754
777
  const limit = resolveLimit(catalogModel, modelsDevLookup);
755
778
  if (limit === void 0) {
756
- logger.warn(
757
- `limit metadata unavailable for ${slug} \u2014 no context/output ceiling will be enforced until added`
758
- );
779
+ if (catalogPresent) {
780
+ logger.warn(
781
+ `limit metadata unavailable for ${slug} \u2014 no context/output ceiling will be enforced until added`
782
+ );
783
+ } else {
784
+ logger.info(
785
+ `not in the current gateway catalog \u2014 serving cached metadata for ${slug}`
786
+ );
787
+ }
759
788
  } else {
760
789
  meta.limit = limit;
761
790
  }
@@ -988,7 +1017,11 @@ async function loadModelsDev(options = {}) {
988
1017
 
989
1018
  // src/config/enrich-catalog.ts
990
1019
  function enrichOne(slug, catalogModel, modelsDev, env) {
991
- const sources = { modelsDev, env };
1020
+ const sources = {
1021
+ catalogPresent: catalogModel !== void 0,
1022
+ modelsDev,
1023
+ env
1024
+ };
992
1025
  if (catalogModel !== void 0) sources.catalogModel = catalogModel;
993
1026
  return enrichModel(slug, sources);
994
1027
  }
@@ -1000,6 +1033,38 @@ function enrichCatalog(catalog, modelsDev, env) {
1000
1033
  return enriched;
1001
1034
  }
1002
1035
 
1036
+ // src/config/merge-provider.ts
1037
+ var FORCED_OPTION_FIELDS = /* @__PURE__ */ new Set([
1038
+ "apiKey",
1039
+ "baseURL",
1040
+ "headers"
1041
+ ]);
1042
+ function mergeProvider(target, patch) {
1043
+ if (target.npm === void 0) target.npm = patch.npm;
1044
+ if (target.name === void 0) target.name = patch.name;
1045
+ if (target.options === void 0) {
1046
+ target.options = { ...patch.options };
1047
+ } else {
1048
+ const options = target.options;
1049
+ for (const [field, value] of Object.entries(patch.options)) {
1050
+ if (value === void 0) continue;
1051
+ if (FORCED_OPTION_FIELDS.has(field) || options[field] === void 0)
1052
+ options[field] = value;
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ // src/config/merge-config.ts
1058
+ function mergeConfig(cfg, blocks) {
1059
+ if (cfg.provider === void 0) cfg.provider = {};
1060
+ const provider = cfg.provider;
1061
+ for (const [providerID, patch] of Object.entries(blocks)) {
1062
+ const target = provider[providerID] ?? {};
1063
+ provider[providerID] = target;
1064
+ mergeProvider(target, patch);
1065
+ }
1066
+ }
1067
+
1003
1068
  // src/config/merge-models.ts
1004
1069
  function cloneEntry(entry) {
1005
1070
  return { ...entry };
@@ -1014,7 +1079,8 @@ function cloneModels(patch) {
1014
1079
  function fillModelEntry(existing, patchEntry) {
1015
1080
  for (const [field, value] of Object.entries(patchEntry)) {
1016
1081
  if (value === void 0) continue;
1017
- if (existing[field] === void 0) existing[field] = value;
1082
+ if (field === "limit" || existing[field] === void 0)
1083
+ existing[field] = value;
1018
1084
  }
1019
1085
  }
1020
1086
  function mergeModels(target, patch) {
@@ -1033,37 +1099,119 @@ function mergeModels(target, patch) {
1033
1099
  }
1034
1100
  }
1035
1101
 
1036
- // src/config/merge-provider.ts
1037
- var FORCED_OPTION_FIELDS = /* @__PURE__ */ new Set([
1038
- "apiKey",
1039
- "baseURL",
1040
- "headers"
1041
- ]);
1042
- function mergeProvider(target, patch) {
1043
- if (target.npm === void 0) target.npm = patch.npm;
1044
- if (target.name === void 0) target.name = patch.name;
1045
- if (target.options === void 0) {
1046
- target.options = { ...patch.options };
1047
- } else {
1048
- const options = target.options;
1049
- for (const [field, value] of Object.entries(patch.options)) {
1050
- if (value === void 0) continue;
1051
- if (FORCED_OPTION_FIELDS.has(field) || options[field] === void 0)
1052
- options[field] = value;
1053
- }
1102
+ // src/config/generation-manifest.ts
1103
+ import * as fs6 from "fs";
1104
+ import * as path7 from "path";
1105
+ var SIDECAR_DIR = ".opencode";
1106
+ var MANIFEST_FILE = "route33-generated.json";
1107
+ var OVERRIDES_FILE = "route33-overrides.json";
1108
+ function isRecord7(value) {
1109
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1110
+ }
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
+ function parseOverrides(value) {
1123
+ if (!isRecord7(value) || !isRecord7(value.models) || !Object.values(value.models).every(isRecord7)) return void 0;
1124
+ const models = {};
1125
+ for (const [key, entry] of Object.entries(value.models)) if (isRecord7(entry)) models[key] = entry;
1126
+ return models;
1127
+ }
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
+ 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) {
1151
+ 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;
1156
+ } 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;
1054
1159
  }
1055
- mergeModels(target, patch);
1056
1160
  }
1057
1161
 
1058
- // src/config/merge-config.ts
1059
- function mergeConfig(cfg, blocks) {
1060
- if (cfg.provider === void 0) cfg.provider = {};
1061
- const provider = cfg.provider;
1062
- for (const [providerID, patch] of Object.entries(blocks)) {
1063
- const target = provider[providerID] ?? {};
1064
- provider[providerID] = target;
1065
- mergeProvider(target, patch);
1162
+ // src/config/regenerate-models.ts
1163
+ import { createHash as createHash2 } from "crypto";
1164
+ function isRecord8(value) {
1165
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1166
+ }
1167
+ function clone(value) {
1168
+ if (Array.isArray(value)) return value.map(clone);
1169
+ if (!isRecord8(value)) return value;
1170
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
1171
+ }
1172
+ function cloneEntry2(entry) {
1173
+ const copy = {};
1174
+ for (const [key, value] of Object.entries(entry)) copy[key] = clone(value);
1175
+ return copy;
1176
+ }
1177
+ function mergeEntry(base, override) {
1178
+ const result = cloneEntry2(base);
1179
+ for (const [key, value] of Object.entries(override)) {
1180
+ const existing = result[key];
1181
+ result[key] = isRecord8(existing) && isRecord8(value) ? mergeEntry(existing, value) : clone(value);
1066
1182
  }
1183
+ return result;
1184
+ }
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
+ function regenerateModels(input) {
1194
+ if (input.source === "none" && input.failedLiveFetch === true) {
1195
+ const models2 = {};
1196
+ for (const [key, entry] of Object.entries(input.currentModels ?? {}))
1197
+ if (entry !== void 0) models2[key] = cloneEntry2(entry);
1198
+ return { models: models2, prunedKeys: [], skipped: true };
1199
+ }
1200
+ const current = input.currentModels ?? {};
1201
+ const generatedKeys = input.previousManifest?.generatedKeys ?? Object.keys(current);
1202
+ 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);
1207
+ for (const [key, override] of Object.entries(input.overrides ?? {}))
1208
+ 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
+ };
1067
1215
  }
1068
1216
 
1069
1217
  // src/config/collected-ref.ts
@@ -1100,66 +1248,6 @@ function collectCfgRefs(cfg) {
1100
1248
  return refs;
1101
1249
  }
1102
1250
 
1103
- // src/config/collect-flavour-refs.ts
1104
- import * as fs6 from "fs";
1105
- import * as os3 from "os";
1106
- import * as path7 from "path";
1107
- import { parse as parseJsonc } from "jsonc-parser";
1108
- function isRecord7(value) {
1109
- return typeof value === "object" && value !== null && !Array.isArray(value);
1110
- }
1111
- function findFlavourPath(env, cwd) {
1112
- const candidates = [];
1113
- const configDir = env.OPENCODE_CONFIG_DIR?.trim();
1114
- if (configDir) candidates.push(path7.join(configDir, "oh-my-openagent.jsonc"));
1115
- candidates.push(path7.join(cwd, ".opencode", "oh-my-openagent.jsonc"));
1116
- const configHome = env.XDG_CONFIG_HOME?.trim() || path7.join(os3.homedir(), ".config");
1117
- candidates.push(path7.join(configHome, "opencode", "oh-my-openagent.jsonc"));
1118
- for (const candidate of candidates) {
1119
- try {
1120
- if (fs6.existsSync(candidate)) return candidate;
1121
- } catch {
1122
- }
1123
- }
1124
- return void 0;
1125
- }
1126
- function collectSectionRefs(refs, section, label) {
1127
- if (!isRecord7(section)) return;
1128
- for (const [name, entry] of Object.entries(section)) {
1129
- if (!isRecord7(entry)) continue;
1130
- pushRef(refs, entry.model, `flavour ${label} "${name}"`);
1131
- if (label === "agent" && isRecord7(entry.ultrawork)) {
1132
- pushRef(
1133
- refs,
1134
- entry.ultrawork.model,
1135
- `flavour ${label} "${name}" (ultrawork)`
1136
- );
1137
- }
1138
- }
1139
- }
1140
- function collectFlavourRefs(env, cwd) {
1141
- const flavourPath = findFlavourPath(env, cwd);
1142
- if (flavourPath === void 0) return [];
1143
- const refs = [];
1144
- try {
1145
- const parsed = parseJsonc(fs6.readFileSync(flavourPath, "utf8"));
1146
- if (!isRecord7(parsed)) {
1147
- logger.warn(
1148
- `flavour file ${flavourPath} did not parse to an object \u2014 skipping flavour ref validation`
1149
- );
1150
- return [];
1151
- }
1152
- collectSectionRefs(refs, parsed.agents, "agent");
1153
- collectSectionRefs(refs, parsed.categories, "category");
1154
- } catch (error) {
1155
- logger.warn(
1156
- `flavour file ${flavourPath} could not be read \u2014 skipping flavour ref validation: ${String(error)}`
1157
- );
1158
- return [];
1159
- }
1160
- return refs;
1161
- }
1162
-
1163
1251
  // src/config/describe-missing.ts
1164
1252
  function availableKeys(cfg, providerId) {
1165
1253
  return Object.keys(cfg.provider?.[providerId]?.models ?? {});
@@ -1289,8 +1377,7 @@ var REMEDIATION = [
1289
1377
  ].join("\n");
1290
1378
  function validateRefs(cfg, options = {}) {
1291
1379
  const env = options.env ?? process.env;
1292
- const cwd = options.cwd ?? process.cwd();
1293
- const refs = [...collectCfgRefs(cfg), ...collectFlavourRefs(env, cwd)];
1380
+ const refs = collectCfgRefs(cfg);
1294
1381
  const missing = findMissingRefs(cfg, refs);
1295
1382
  if (missing.length === 0) return;
1296
1383
  const unresolved = [];
@@ -1378,6 +1465,29 @@ function createConfigHook(options = {}) {
1378
1465
  resolveKeyNow: () => resolveKey(void 0, env, cwd)
1379
1466
  });
1380
1467
  mergeConfig(cfg, blocks);
1468
+ const mergeableConfig = cfg;
1469
+ const provider = mergeableConfig.provider?.route_33;
1470
+ if (provider !== void 0) {
1471
+ const regeneration = regenerateModels({
1472
+ currentModels: provider.models,
1473
+ freshModels: blocks.route_33.models,
1474
+ previousManifest: readGenerationManifest(cwd),
1475
+ overrides: readModelOverrides(cwd),
1476
+ source: catalog.source,
1477
+ failedLiveFetch: catalog.liveFetchFailed
1478
+ });
1479
+ if (regeneration.skipped) {
1480
+ logger.info("route_33: catalog fetch failed without a cache; preserving existing managed model entries");
1481
+ } 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`);
1489
+ }
1490
+ }
1381
1491
  } catch (error) {
1382
1492
  logger.error(
1383
1493
  `model discovery/injection failed unexpectedly \u2014 opencode startup continues without injected models: ${describeError(error)}`
@@ -1386,7 +1496,6 @@ function createConfigHook(options = {}) {
1386
1496
  try {
1387
1497
  validateRefs(cfg, {
1388
1498
  env,
1389
- cwd,
1390
1499
  keyResolved: key !== void 0,
1391
1500
  catalogAvailable
1392
1501
  });
@@ -1,9 +1,9 @@
1
- # Gateway limits and variants broadcast proposal
1
+ # Gateway model metadata broadcast
2
2
 
3
3
  **To:** R33 gateway team
4
4
  **From:** opencode-route33 plugin maintainers
5
5
  **Date:** 2026-08-10
6
- **Subject:** Embedding per-model limits and variants in `GET /v1/models`
6
+ **Subject:** Shipped per-model metadata in `GET /v1/models`
7
7
 
8
8
  ## Problem
9
9
 
@@ -19,10 +19,12 @@ This creates three gaps:
19
19
 
20
20
  ## Recommendation
21
21
 
22
- Extend each entry of the existing `GET /v1/models` response with two new optional fields:
22
+ **Status: shipped.** Each entry of the existing `GET /v1/models` response supports these optional metadata fields:
23
23
 
24
24
  - `limits`: `{ "context": number, "output": number }`, present only when both values are positive integers.
25
25
  - `variants`: `Record<string, Record<string, unknown>>`, passed through verbatim from the platform's curated catalog.
26
+ - `modalities`: `{ "input": ["text"|"audio"|"image"|"video"|"pdf"], "output": ["text"|"audio"|"image"|"video"|"pdf"] }`.
27
+ - `attachment`: boolean, derived by the gateway as true when `modalities.input` contains `image` or `pdf`.
26
28
 
27
29
  Existing unchanged fields (`id`, `object`, `created`, `owned_by`, plus optional `display_name`, `pricing`, `capabilities`, and `endpoints`) remain unchanged. Extra JSON fields still do not break OpenAI-compatible clients, so the catalog stays one round trip and atomically consistent.
28
30
 
@@ -61,7 +63,9 @@ Existing unchanged fields (`id`, `object`, `created`, `owned_by`, plus optional
61
63
  "max": {
62
64
  "reasoningEffort": "max"
63
65
  }
64
- }
66
+ },
67
+ "modalities": { "input": ["text", "image"], "output": ["text"] },
68
+ "attachment": true
65
69
  }
66
70
  ]
67
71
  }
@@ -89,6 +93,10 @@ Existing unchanged fields (`id`, `object`, `created`, `owned_by`, plus optional
89
93
  | `limits.context` | positive integer | Maximum context-window tokens |
90
94
  | `limits.output` | positive integer | Maximum completion tokens |
91
95
  | `variants` | object or omitted | Per-model parameter variants, a record of records |
96
+ | `modalities` | object or omitted | Exact supported input and output media types |
97
+ | `modalities.input` | array | Zero or more of `text`, `audio`, `image`, `video`, `pdf` |
98
+ | `modalities.output` | array | Zero or more of `text`, `audio`, `image`, `video`, `pdf` |
99
+ | `attachment` | boolean or omitted | Gateway-derived file/image support (`input` includes `image` or `pdf`) |
92
100
 
93
101
  ### Consumer parse rules
94
102
 
@@ -98,6 +106,9 @@ The opencode-route33 plugin consumes this shape with the following rules:
98
106
  - `capabilities.reasoning` with the boolean value `true` maps to opencode's `reasoning` flag. Any non-boolean value is skipped.
99
107
  - `capabilities.tool_support` with the boolean value `true` maps to opencode's `tool_call` flag. Any non-boolean value is skipped.
100
108
  - `variants` is accepted only when it is a `Record<string, Record<string, unknown>>` and is passed through verbatim. Malformed variants are skipped.
109
+ - `modalities` is accepted only as an object with array `input` and `output` fields. Consumers drop unrecognized modality values; malformed modality shapes are omitted for that one model.
110
+ - `attachment` is accepted only when boolean; a non-boolean value is omitted for that one model.
111
+ - `capabilities.modality` is legacy qualitative metadata. `modalities` is the authoritative exact media contract.
101
112
  - Unknown or malformed fields are ignored. Parsing is never fatal.
102
113
 
103
114
  ### Why embed limits and variants on `/v1/models`
@@ -129,3 +140,7 @@ It should return the same per-model fields and types documented above (`id`, `ob
129
140
  ## Secondary option (later)
130
141
 
131
142
  Publish route33 limit and variant metadata on models.dev via a community TOML PR. This is useful for non-gateway consumers, but it does not solve the key-scoped freshness problem or the single-round-trip goal. Recommended as a follow-up, not a substitute.
143
+
144
+ ## Future direction
145
+
146
+ Broadcasting `reasoning_options` is deferred. The current shipped contract intentionally limits itself to limits, variants, modalities, attachment, pricing, and capabilities.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@route_33/opencode",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "description": "opencode provider plugin for the route33.ai AI gateway — key-scoped model discovery, cost/limit enrichment, auth, and the key-setup wizard.",
@@ -40,9 +40,6 @@
40
40
  "peerDependencies": {
41
41
  "@opencode-ai/plugin": ">=1.17.0 <2.0.0"
42
42
  },
43
- "dependencies": {
44
- "jsonc-parser": "^3.3.1"
45
- },
46
43
  "devDependencies": {
47
44
  "@opencode-ai/plugin": "^1.17.12",
48
45
  "@types/node": "^22.10.7",