@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 +23 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +208 -99
- package/docs/gateway-limits-variants-broadcast.md +19 -4
- package/package.json +1 -4
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
757
|
-
|
|
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 = {
|
|
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 (
|
|
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/
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
function
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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/
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
|
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
|
|
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:**
|
|
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
|
-
|
|
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.
|
|
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",
|