@route_33/opencode 0.4.1 → 0.4.3

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,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 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
 
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:
@@ -37,9 +39,9 @@ The catalog is discovered dynamically. The plugin calls `GET {baseURL}/models` w
37
39
  1. Return the fresh on-disk cache if it has not expired.
38
40
  2. Fetch live from the gateway and update the cache.
39
41
  3. If the live fetch fails, return the stale cache.
40
- 4. If there is no cache and no live response, inject zero models.
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.
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.
43
45
 
44
46
  If discovery yields no catalog, the plugin logs exactly one warning:
45
47
 
@@ -55,10 +57,29 @@ Models are decorated with metadata from the route33 gateway broadcast; `models.d
55
57
 
56
58
  - **Cost**: the gateway's broadcast pricing wins; `models.dev` fills in when the broadcast carries no pricing.
57
59
  - **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.
60
+ - **Modalities**: gateway `modalities.input` / `modalities.output` are passed through to opencode. Invalid modality values and malformed modality objects are omitted per model.
61
+ - **Attachment**: gateway `attachment` wins whenever present. `models.dev` supplies it only when the gateway omits the field.
58
62
  - **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
63
  - **Variants**: R33 broadcast variants are passed verbatim (for example, `kimi-k3`'s `max` `reasoningEffort` variant).
60
64
 
61
- The normative contract for limits and variants is [docs/gateway-limits-variants-broadcast.md](docs/gateway-limits-variants-broadcast.md).
65
+ The normative broadcast contract is [docs/gateway-limits-variants-broadcast.md](docs/gateway-limits-variants-broadcast.md).
66
+
67
+ ## Managed model entries
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.
70
+
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
+
73
+ ```json
74
+ {
75
+ "models": {
76
+ "openai/gpt-5.5": { "variants": { "fast": { "temperature": 0 } } },
77
+ "custom/internal": { "name": "Internal model" }
78
+ }
79
+ }
80
+ ```
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.
62
83
 
63
84
  `ROUTE33_ENRICH_CAPS` has been removed; capability backfill from `models.dev` is now always on.
64
85
 
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
@@ -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
 
@@ -328,7 +324,10 @@ function toModelPatch(meta, routingSlug, forceReasoning, nativeNpm) {
328
324
  if (routingSlug !== void 0) entry.id = routingSlug;
329
325
  if (nativeNpm !== void 0) entry.provider = { npm: nativeNpm };
330
326
  const caps = meta.capabilities;
331
- if (caps.attachment !== void 0) entry.attachment = caps.attachment;
327
+ if (meta.attachment !== void 0) entry.attachment = meta.attachment;
328
+ else if (caps.attachment !== void 0) entry.attachment = caps.attachment;
329
+ if (meta.modalities !== void 0)
330
+ entry.modalities = { input: [...meta.modalities.input], output: [...meta.modalities.output] };
332
331
  if (caps.reasoning !== void 0) entry.reasoning = caps.reasoning;
333
332
  if (forceReasoning) entry.reasoning = true;
334
333
  if (caps.temperature !== void 0) entry.temperature = caps.temperature;
@@ -347,7 +346,7 @@ function vendorOf(slug) {
347
346
  return slug.slice(0, slug.indexOf("/"));
348
347
  }
349
348
  function buildProviderBlocks(enriched, options) {
350
- const { apiKey, baseURL } = options;
349
+ const { baseURL } = options;
351
350
  const resolveKeyNow = options.resolveKeyNow ?? (() => void 0);
352
351
  const route33Models = {};
353
352
  for (const slug of Object.keys(enriched).sort()) {
@@ -366,7 +365,7 @@ function buildProviderBlocks(enriched, options) {
366
365
  route_33: {
367
366
  npm: "@ai-sdk/openai-compatible",
368
367
  name: PROVIDER_DISPLAY_NAME,
369
- options: buildOptions(baseURL, apiKey, resolveKeyNow),
368
+ options: buildOptions(baseURL, resolveKeyNow),
370
369
  models: route33Models
371
370
  }
372
371
  };
@@ -390,6 +389,9 @@ function cacheFilePath(keyHash, env) {
390
389
  return path5.join(resolveCacheDir(env), `catalog-${keyHash}.json`);
391
390
  }
392
391
 
392
+ // src/catalog/catalog-cache/cache-version.ts
393
+ var CATALOG_CACHE_VERSION = 2;
394
+
393
395
  // src/catalog/catalog-cache/read-disk.ts
394
396
  function isRecord3(value) {
395
397
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -399,7 +401,7 @@ function readDisk(keyHash, env) {
399
401
  const parsed = JSON.parse(
400
402
  fs3.readFileSync(cacheFilePath(keyHash, env), "utf8")
401
403
  );
402
- if (!isRecord3(parsed) || typeof parsed.fetchedAt !== "number" || !isRecord3(parsed.models))
404
+ if (!isRecord3(parsed) || parsed.version !== CATALOG_CACHE_VERSION || typeof parsed.fetchedAt !== "number" || !isRecord3(parsed.models))
403
405
  return void 0;
404
406
  return {
405
407
  fetchedAt: parsed.fetchedAt,
@@ -449,7 +451,10 @@ function writeCatalog(opts) {
449
451
  memory.set(keyHash, record);
450
452
  try {
451
453
  fs4.mkdirSync(resolveCacheDir(env), { recursive: true });
452
- fs4.writeFileSync(cacheFilePath(keyHash, env), JSON.stringify(record));
454
+ fs4.writeFileSync(
455
+ cacheFilePath(keyHash, env),
456
+ JSON.stringify({ version: CATALOG_CACHE_VERSION, ...record })
457
+ );
453
458
  } catch (error) {
454
459
  logger.warn(
455
460
  `could not persist catalog cache: ${error instanceof Error ? error.message : String(error)}`
@@ -593,6 +598,13 @@ function parseVariants(raw) {
593
598
  if (!Object.values(raw).every(isRecord5)) return void 0;
594
599
  return raw;
595
600
  }
601
+ function isModality(value) {
602
+ return value === "text" || value === "audio" || value === "image" || value === "video" || value === "pdf";
603
+ }
604
+ function parseModalities(raw) {
605
+ if (!isRecord5(raw) || !Array.isArray(raw.input) || !Array.isArray(raw.output)) return void 0;
606
+ return { input: raw.input.filter(isModality), output: raw.output.filter(isModality) };
607
+ }
596
608
  function toCatalogModels(entries, env) {
597
609
  const models = {};
598
610
  for (const entry of entries) {
@@ -610,6 +622,9 @@ function toCatalogModels(entries, env) {
610
622
  if (capabilities !== void 0) model.capabilities = capabilities;
611
623
  const variants = parseVariants(entry.variants);
612
624
  if (variants !== void 0) model.variants = variants;
625
+ const modalities = parseModalities(entry.modalities);
626
+ if (modalities !== void 0) model.modalities = modalities;
627
+ if (typeof entry.attachment === "boolean") model.attachment = entry.attachment;
613
628
  models[slug] = model;
614
629
  }
615
630
  return models;
@@ -707,7 +722,7 @@ async function runLadder(opts) {
707
722
  );
708
723
  return { models: stale, source: "stale-cache" };
709
724
  }
710
- return noneResult("live fetch failed and no cache available", false);
725
+ return { ...noneResult("live fetch failed and no cache available", false), liveFetchFailed: true };
711
726
  }
712
727
  async function getCatalog(opts) {
713
728
  try {
@@ -748,14 +763,24 @@ function resolveCapabilities(catalogModel, modelsDevLookup) {
748
763
  function enrichModel(slug, sources) {
749
764
  const modelsDevLookup = sources.modelsDev.lookup(slug);
750
765
  const catalogModel = sources.catalogModel;
766
+ const catalogPresent = sources.catalogPresent ?? catalogModel !== void 0;
751
767
  const capabilities = resolveCapabilities(catalogModel, modelsDevLookup);
752
768
  const name = catalogModel !== void 0 && catalogModel.id === slug && catalogModel.displayName !== void 0 ? catalogModel.displayName : slug;
753
769
  const meta = { name, capabilities };
770
+ if (catalogModel?.modalities !== void 0) meta.modalities = catalogModel.modalities;
771
+ const attachment = catalogModel?.attachment ?? modelsDevLookup?.attachment;
772
+ if (attachment !== void 0) meta.attachment = attachment;
754
773
  const limit = resolveLimit(catalogModel, modelsDevLookup);
755
774
  if (limit === void 0) {
756
- logger.warn(
757
- `limit metadata unavailable for ${slug} \u2014 no context/output ceiling will be enforced until added`
758
- );
775
+ if (catalogPresent) {
776
+ logger.warn(
777
+ `limit metadata unavailable for ${slug} \u2014 no context/output ceiling will be enforced until added`
778
+ );
779
+ } else {
780
+ logger.info(
781
+ `not in the current gateway catalog \u2014 serving cached metadata for ${slug}`
782
+ );
783
+ }
759
784
  } else {
760
785
  meta.limit = limit;
761
786
  }
@@ -988,7 +1013,11 @@ async function loadModelsDev(options = {}) {
988
1013
 
989
1014
  // src/config/enrich-catalog.ts
990
1015
  function enrichOne(slug, catalogModel, modelsDev, env) {
991
- const sources = { modelsDev, env };
1016
+ const sources = {
1017
+ catalogPresent: catalogModel !== void 0,
1018
+ modelsDev,
1019
+ env
1020
+ };
992
1021
  if (catalogModel !== void 0) sources.catalogModel = catalogModel;
993
1022
  return enrichModel(slug, sources);
994
1023
  }
@@ -1000,6 +1029,38 @@ function enrichCatalog(catalog, modelsDev, env) {
1000
1029
  return enriched;
1001
1030
  }
1002
1031
 
1032
+ // src/config/merge-provider.ts
1033
+ var FORCED_OPTION_FIELDS = /* @__PURE__ */ new Set([
1034
+ "apiKey",
1035
+ "baseURL",
1036
+ "headers"
1037
+ ]);
1038
+ function mergeProvider(target, patch) {
1039
+ if (target.npm === void 0) target.npm = patch.npm;
1040
+ if (target.name === void 0) target.name = patch.name;
1041
+ if (target.options === void 0) {
1042
+ target.options = { ...patch.options };
1043
+ } else {
1044
+ const options = target.options;
1045
+ for (const [field, value] of Object.entries(patch.options)) {
1046
+ if (value === void 0) continue;
1047
+ if (FORCED_OPTION_FIELDS.has(field) || options[field] === void 0)
1048
+ options[field] = value;
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ // src/config/merge-config.ts
1054
+ function mergeConfig(cfg, blocks) {
1055
+ if (cfg.provider === void 0) cfg.provider = {};
1056
+ const provider = cfg.provider;
1057
+ for (const [providerID, patch] of Object.entries(blocks)) {
1058
+ const target = provider[providerID] ?? {};
1059
+ provider[providerID] = target;
1060
+ mergeProvider(target, patch);
1061
+ }
1062
+ }
1063
+
1003
1064
  // src/config/merge-models.ts
1004
1065
  function cloneEntry(entry) {
1005
1066
  return { ...entry };
@@ -1034,37 +1095,119 @@ function mergeModels(target, patch) {
1034
1095
  }
1035
1096
  }
1036
1097
 
1037
- // src/config/merge-provider.ts
1038
- var FORCED_OPTION_FIELDS = /* @__PURE__ */ new Set([
1039
- "apiKey",
1040
- "baseURL",
1041
- "headers"
1042
- ]);
1043
- function mergeProvider(target, patch) {
1044
- if (target.npm === void 0) target.npm = patch.npm;
1045
- if (target.name === void 0) target.name = patch.name;
1046
- if (target.options === void 0) {
1047
- target.options = { ...patch.options };
1048
- } else {
1049
- const options = target.options;
1050
- for (const [field, value] of Object.entries(patch.options)) {
1051
- if (value === void 0) continue;
1052
- if (FORCED_OPTION_FIELDS.has(field) || options[field] === void 0)
1053
- options[field] = value;
1054
- }
1098
+ // src/config/generation-manifest.ts
1099
+ import * as fs6 from "fs";
1100
+ import * as path7 from "path";
1101
+ var SIDECAR_DIR = ".opencode";
1102
+ var MANIFEST_FILE = "route33-generated.json";
1103
+ var OVERRIDES_FILE = "route33-overrides.json";
1104
+ function isRecord7(value) {
1105
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1106
+ }
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
+ function parseOverrides(value) {
1119
+ if (!isRecord7(value) || !isRecord7(value.models) || !Object.values(value.models).every(isRecord7)) return void 0;
1120
+ const models = {};
1121
+ for (const [key, entry] of Object.entries(value.models)) if (isRecord7(entry)) models[key] = entry;
1122
+ return models;
1123
+ }
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
+ 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) {
1147
+ 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;
1152
+ } 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;
1055
1155
  }
1056
- mergeModels(target, patch);
1057
1156
  }
1058
1157
 
1059
- // src/config/merge-config.ts
1060
- function mergeConfig(cfg, blocks) {
1061
- if (cfg.provider === void 0) cfg.provider = {};
1062
- const provider = cfg.provider;
1063
- for (const [providerID, patch] of Object.entries(blocks)) {
1064
- const target = provider[providerID] ?? {};
1065
- provider[providerID] = target;
1066
- mergeProvider(target, patch);
1158
+ // src/config/regenerate-models.ts
1159
+ import { createHash as createHash2 } from "crypto";
1160
+ function isRecord8(value) {
1161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1162
+ }
1163
+ function clone(value) {
1164
+ if (Array.isArray(value)) return value.map(clone);
1165
+ if (!isRecord8(value)) return value;
1166
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
1167
+ }
1168
+ function cloneEntry2(entry) {
1169
+ const copy = {};
1170
+ for (const [key, value] of Object.entries(entry)) copy[key] = clone(value);
1171
+ return copy;
1172
+ }
1173
+ function mergeEntry(base, override) {
1174
+ const result = cloneEntry2(base);
1175
+ for (const [key, value] of Object.entries(override)) {
1176
+ const existing = result[key];
1177
+ result[key] = isRecord8(existing) && isRecord8(value) ? mergeEntry(existing, value) : clone(value);
1178
+ }
1179
+ return result;
1180
+ }
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
+ function regenerateModels(input) {
1190
+ if (input.source === "none" && input.failedLiveFetch === true) {
1191
+ const models2 = {};
1192
+ for (const [key, entry] of Object.entries(input.currentModels ?? {}))
1193
+ if (entry !== void 0) models2[key] = cloneEntry2(entry);
1194
+ return { models: models2, prunedKeys: [], skipped: true };
1195
+ }
1196
+ const current = input.currentModels ?? {};
1197
+ const generatedKeys = input.previousManifest?.generatedKeys ?? Object.keys(current);
1198
+ const models = {};
1199
+ for (const [key, entry] of Object.entries(current)) {
1200
+ if (!generatedKeys.includes(key) && entry !== void 0) models[key] = cloneEntry2(entry);
1067
1201
  }
1202
+ for (const [key, entry] of Object.entries(input.freshModels)) models[key] = cloneEntry2(entry);
1203
+ for (const [key, override] of Object.entries(input.overrides ?? {}))
1204
+ 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
+ };
1068
1211
  }
1069
1212
 
1070
1213
  // src/config/collected-ref.ts
@@ -1101,66 +1244,6 @@ function collectCfgRefs(cfg) {
1101
1244
  return refs;
1102
1245
  }
1103
1246
 
1104
- // src/config/collect-flavour-refs.ts
1105
- import * as fs6 from "fs";
1106
- import * as os3 from "os";
1107
- import * as path7 from "path";
1108
- import { parse as parseJsonc } from "jsonc-parser";
1109
- function isRecord7(value) {
1110
- return typeof value === "object" && value !== null && !Array.isArray(value);
1111
- }
1112
- function findFlavourPath(env, cwd) {
1113
- const candidates = [];
1114
- const configDir = env.OPENCODE_CONFIG_DIR?.trim();
1115
- if (configDir) candidates.push(path7.join(configDir, "oh-my-openagent.jsonc"));
1116
- candidates.push(path7.join(cwd, ".opencode", "oh-my-openagent.jsonc"));
1117
- const configHome = env.XDG_CONFIG_HOME?.trim() || path7.join(os3.homedir(), ".config");
1118
- candidates.push(path7.join(configHome, "opencode", "oh-my-openagent.jsonc"));
1119
- for (const candidate of candidates) {
1120
- try {
1121
- if (fs6.existsSync(candidate)) return candidate;
1122
- } catch {
1123
- }
1124
- }
1125
- return void 0;
1126
- }
1127
- function collectSectionRefs(refs, section, label) {
1128
- if (!isRecord7(section)) return;
1129
- for (const [name, entry] of Object.entries(section)) {
1130
- if (!isRecord7(entry)) continue;
1131
- pushRef(refs, entry.model, `flavour ${label} "${name}"`);
1132
- if (label === "agent" && isRecord7(entry.ultrawork)) {
1133
- pushRef(
1134
- refs,
1135
- entry.ultrawork.model,
1136
- `flavour ${label} "${name}" (ultrawork)`
1137
- );
1138
- }
1139
- }
1140
- }
1141
- function collectFlavourRefs(env, cwd) {
1142
- const flavourPath = findFlavourPath(env, cwd);
1143
- if (flavourPath === void 0) return [];
1144
- const refs = [];
1145
- try {
1146
- const parsed = parseJsonc(fs6.readFileSync(flavourPath, "utf8"));
1147
- if (!isRecord7(parsed)) {
1148
- logger.warn(
1149
- `flavour file ${flavourPath} did not parse to an object \u2014 skipping flavour ref validation`
1150
- );
1151
- return [];
1152
- }
1153
- collectSectionRefs(refs, parsed.agents, "agent");
1154
- collectSectionRefs(refs, parsed.categories, "category");
1155
- } catch (error) {
1156
- logger.warn(
1157
- `flavour file ${flavourPath} could not be read \u2014 skipping flavour ref validation: ${String(error)}`
1158
- );
1159
- return [];
1160
- }
1161
- return refs;
1162
- }
1163
-
1164
1247
  // src/config/describe-missing.ts
1165
1248
  function availableKeys(cfg, providerId) {
1166
1249
  return Object.keys(cfg.provider?.[providerId]?.models ?? {});
@@ -1290,8 +1373,7 @@ var REMEDIATION = [
1290
1373
  ].join("\n");
1291
1374
  function validateRefs(cfg, options = {}) {
1292
1375
  const env = options.env ?? process.env;
1293
- const cwd = options.cwd ?? process.cwd();
1294
- const refs = [...collectCfgRefs(cfg), ...collectFlavourRefs(env, cwd)];
1376
+ const refs = collectCfgRefs(cfg);
1295
1377
  const missing = findMissingRefs(cfg, refs);
1296
1378
  if (missing.length === 0) return;
1297
1379
  const unresolved = [];
@@ -1371,14 +1453,36 @@ function createConfigHook(options = {}) {
1371
1453
  const modelsDev = await loadModelsDev(modelsDevOptions);
1372
1454
  const enriched = enrichCatalog(catalog, modelsDev, env);
1373
1455
  const blocks = buildProviderBlocks(enriched, {
1374
- apiKey: key,
1375
1456
  baseURL,
1376
1457
  // Per-request resolution for the live-auth fetch: a prompt-entered
1377
- // key applies to the very next request, no restart. The static
1378
- // apiKey above stays the boot-time/catalog path.
1458
+ // key applies to the very next request, no restart. The block never
1459
+ // carries the key itself, so config dumps stay credential-free.
1379
1460
  resolveKeyNow: () => resolveKey(void 0, env, cwd)
1380
1461
  });
1381
1462
  mergeConfig(cfg, blocks);
1463
+ const mergeableConfig = cfg;
1464
+ const provider = mergeableConfig.provider?.route_33;
1465
+ if (provider !== void 0) {
1466
+ const regeneration = regenerateModels({
1467
+ currentModels: provider.models,
1468
+ freshModels: blocks.route_33.models,
1469
+ previousManifest: readGenerationManifest(cwd),
1470
+ overrides: readModelOverrides(cwd),
1471
+ source: catalog.source,
1472
+ failedLiveFetch: catalog.liveFetchFailed
1473
+ });
1474
+ if (regeneration.skipped) {
1475
+ logger.info("route_33: catalog fetch failed without a cache; preserving existing managed model entries");
1476
+ } 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`);
1484
+ }
1485
+ }
1382
1486
  } catch (error) {
1383
1487
  logger.error(
1384
1488
  `model discovery/injection failed unexpectedly \u2014 opencode startup continues without injected models: ${describeError(error)}`
@@ -1387,7 +1491,6 @@ function createConfigHook(options = {}) {
1387
1491
  try {
1388
1492
  validateRefs(cfg, {
1389
1493
  env,
1390
- cwd,
1391
1494
  keyResolved: key !== void 0,
1392
1495
  catalogAvailable
1393
1496
  });
@@ -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.1",
3
+ "version": "0.4.3",
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",