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