@route_33/opencode 0.3.0 → 0.4.0
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 +51 -2
- package/dist/index.js +178 -367
- package/docs/gateway-limits-variants-broadcast.md +131 -0
- package/docs/gateway-pricing-proposal.md +96 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -13,14 +13,14 @@ The installer patches both configs: the server plugin into `opencode.json` and t
|
|
|
13
13
|
```jsonc
|
|
14
14
|
// opencode.json — server plugin (model discovery, cost/limit enrichment, auth)
|
|
15
15
|
{
|
|
16
|
-
"plugin": ["@route_33/opencode
|
|
16
|
+
"plugin": ["@route_33/opencode"]
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
```jsonc
|
|
21
21
|
// tui.json — TUI plugin (the key-setup wizard)
|
|
22
22
|
{
|
|
23
|
-
"plugin": ["@route_33/opencode
|
|
23
|
+
"plugin": ["@route_33/opencode"]
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -30,6 +30,55 @@ This plugin never writes your config: opencode has no TUI-plugin auto-discovery,
|
|
|
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
|
+
## Model catalog
|
|
34
|
+
|
|
35
|
+
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:
|
|
36
|
+
|
|
37
|
+
1. Return the fresh on-disk cache if it has not expired.
|
|
38
|
+
2. Fetch live from the gateway and update the cache.
|
|
39
|
+
3. If the live fetch fails, return the stale cache.
|
|
40
|
+
4. If there is no cache and no live response, inject zero models.
|
|
41
|
+
|
|
42
|
+
The plugin does not embed or serve a static catalog.
|
|
43
|
+
|
|
44
|
+
If discovery yields no catalog, the plugin logs exactly one warning:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
route33: no model catalog available — 0 models injected (reason). route33 is unusable without a catalog: resolve an API key (opencode auth login / ROUTE33_API_KEY) — the key dictates which models are available.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `ROUTE33_DISCOVERY=0` to opt out of discovery entirely. This is logged at `info` and injects zero models without the warning.
|
|
51
|
+
|
|
52
|
+
## Metadata provenance
|
|
53
|
+
|
|
54
|
+
Models are decorated with metadata from the route33 gateway broadcast; `models.dev` is used only as a backfill source.
|
|
55
|
+
|
|
56
|
+
- **Cost**: the gateway's broadcast pricing wins; `models.dev` fills in when the broadcast carries no pricing.
|
|
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
|
+
- **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
|
+
- **Variants**: R33 broadcast variants are passed verbatim (for example, `kimi-k3`'s `max` `reasoningEffort` variant).
|
|
60
|
+
|
|
61
|
+
The normative contract for limits and variants is [docs/gateway-limits-variants-broadcast.md](docs/gateway-limits-variants-broadcast.md).
|
|
62
|
+
|
|
63
|
+
`ROUTE33_ENRICH_CAPS` has been removed; capability backfill from `models.dev` is now always on.
|
|
64
|
+
|
|
65
|
+
## Model-reference tolerance
|
|
66
|
+
|
|
67
|
+
Model references in your config that differ from the served ID only by digit-boundary dots versus dashes in the tail resolve automatically. For example, `anthropic/claude-opus-4.8` resolves to the served `anthropic/claude-opus-4-8` and is injected with the served wire ID. Arbitrary renames, such as `gpt5.5` for `openai/gpt-5.5`, do not resolve.
|
|
68
|
+
|
|
69
|
+
## Environment variables
|
|
70
|
+
|
|
71
|
+
| Variable | Effect |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `ROUTE33_API_KEY` | Gateway API key; highest precedence. See Auth. |
|
|
74
|
+
| `ROUTE33_BASE_URL` | Override the default gateway base URL. See Auth. |
|
|
75
|
+
| `ROUTE33_TUI_PROMPT` | Set to `0` to disable the key-setup wizard. See Auth. |
|
|
76
|
+
| `ROUTE33_DISCOVERY` | Set to `0` to disable dynamic catalog discovery. |
|
|
77
|
+
| `ROUTE33_STRICT` | Set to `0` to downgrade unknown-model reference errors to a warning plus a stub entry. |
|
|
78
|
+
| `ROUTE33_CACHE_DIR` | Override the directory used for the on-disk catalog cache. |
|
|
79
|
+
|
|
80
|
+
The plugin reads only these variables. `ROUTE33_ENRICH_CAPS` is no longer supported.
|
|
81
|
+
|
|
33
82
|
## License
|
|
34
83
|
|
|
35
84
|
Proprietary — "SEE LICENSE IN LICENSE". Install and use as-is via npm; copying, modification, and redistribution are not permitted. Provided AS-IS, without warranty or liability.
|
package/dist/index.js
CHANGED
|
@@ -58,9 +58,6 @@ var ENV_DISABLE_ID_SHIM = "ROUTE33_DISABLE_ID_SHIM";
|
|
|
58
58
|
// src/shared/env-discovery.ts
|
|
59
59
|
var ENV_DISCOVERY = "ROUTE33_DISCOVERY";
|
|
60
60
|
|
|
61
|
-
// src/shared/env-enrich-caps.ts
|
|
62
|
-
var ENV_ENRICH_CAPS = "ROUTE33_ENRICH_CAPS";
|
|
63
|
-
|
|
64
61
|
// src/shared/env-strict.ts
|
|
65
62
|
var ENV_STRICT = "ROUTE33_STRICT";
|
|
66
63
|
|
|
@@ -346,22 +343,21 @@ function toModelPatch(meta, routingSlug, forceReasoning, nativeNpm) {
|
|
|
346
343
|
|
|
347
344
|
// src/config/build-provider-blocks.ts
|
|
348
345
|
var PROVIDER_DISPLAY_NAME = "route_33";
|
|
349
|
-
function vendorOf(
|
|
350
|
-
return
|
|
346
|
+
function vendorOf(slug) {
|
|
347
|
+
return slug.slice(0, slug.indexOf("/"));
|
|
351
348
|
}
|
|
352
349
|
function buildProviderBlocks(enriched, options) {
|
|
353
|
-
const { apiKey, baseURL
|
|
350
|
+
const { apiKey, baseURL } = options;
|
|
354
351
|
const resolveKeyNow = options.resolveKeyNow ?? (() => void 0);
|
|
355
352
|
const route33Models = {};
|
|
356
|
-
for (const
|
|
357
|
-
const meta = enriched[
|
|
353
|
+
for (const slug of Object.keys(enriched).sort()) {
|
|
354
|
+
const meta = enriched[slug];
|
|
358
355
|
if (meta === void 0) continue;
|
|
359
|
-
const
|
|
360
|
-
const vendor = vendorOf(slug2);
|
|
356
|
+
const vendor = vendorOf(slug);
|
|
361
357
|
const nativeNpm = NATIVE_SDK_BY_VENDOR[vendor];
|
|
362
|
-
route33Models[
|
|
358
|
+
route33Models[slug] = toModelPatch(
|
|
363
359
|
meta,
|
|
364
|
-
|
|
360
|
+
void 0,
|
|
365
361
|
vendor === "anthropic",
|
|
366
362
|
nativeNpm
|
|
367
363
|
);
|
|
@@ -576,18 +572,45 @@ function parseEntries(payload) {
|
|
|
576
572
|
"response is neither {data:[\u2026]} nor a bare array"
|
|
577
573
|
);
|
|
578
574
|
}
|
|
575
|
+
function positiveInteger(value) {
|
|
576
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
577
|
+
}
|
|
578
|
+
function parseLimit(raw) {
|
|
579
|
+
if (!isRecord5(raw)) return void 0;
|
|
580
|
+
if (!positiveInteger(raw.context) || !positiveInteger(raw.output))
|
|
581
|
+
return void 0;
|
|
582
|
+
return { context: raw.context, output: raw.output };
|
|
583
|
+
}
|
|
584
|
+
function parseCapabilities(raw) {
|
|
585
|
+
if (!isRecord5(raw)) return void 0;
|
|
586
|
+
const capabilities = {};
|
|
587
|
+
if (raw.reasoning === true) capabilities.reasoning = true;
|
|
588
|
+
if (raw.tool_support === true) capabilities.tool_call = true;
|
|
589
|
+
return Object.keys(capabilities).length > 0 ? capabilities : void 0;
|
|
590
|
+
}
|
|
591
|
+
function parseVariants(raw) {
|
|
592
|
+
if (!isRecord5(raw)) return void 0;
|
|
593
|
+
if (!Object.values(raw).every(isRecord5)) return void 0;
|
|
594
|
+
return raw;
|
|
595
|
+
}
|
|
579
596
|
function toCatalogModels(entries, env) {
|
|
580
597
|
const models = {};
|
|
581
598
|
for (const entry of entries) {
|
|
582
599
|
if (!isRecord5(entry) || typeof entry.id !== "string") continue;
|
|
583
|
-
const
|
|
584
|
-
if (!
|
|
585
|
-
const model = { id:
|
|
600
|
+
const slug = normalizeId(entry.id, env);
|
|
601
|
+
if (!slug || models[slug]) continue;
|
|
602
|
+
const model = { id: slug };
|
|
586
603
|
const displayName = typeof entry.display_name === "string" ? entry.display_name.trim() : "";
|
|
587
604
|
if (displayName !== "") model.displayName = displayName;
|
|
588
605
|
if (isRecord5(entry.pricing) && extractPricing(entry.pricing) !== void 0)
|
|
589
606
|
model.pricing = entry.pricing;
|
|
590
|
-
|
|
607
|
+
const limit = parseLimit(entry.limits);
|
|
608
|
+
if (limit !== void 0) model.limit = limit;
|
|
609
|
+
const capabilities = parseCapabilities(entry.capabilities);
|
|
610
|
+
if (capabilities !== void 0) model.capabilities = capabilities;
|
|
611
|
+
const variants = parseVariants(entry.variants);
|
|
612
|
+
if (variants !== void 0) model.variants = variants;
|
|
613
|
+
models[slug] = model;
|
|
591
614
|
}
|
|
592
615
|
return models;
|
|
593
616
|
}
|
|
@@ -647,19 +670,17 @@ async function fetchCatalog(opts) {
|
|
|
647
670
|
}
|
|
648
671
|
|
|
649
672
|
// src/catalog/catalog-service.ts
|
|
650
|
-
function
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
logger.info(`catalog source: seed (${why}; ${seed.length} model(s))`);
|
|
656
|
-
return { models, source: "seed" };
|
|
673
|
+
function noneResult(reason, optOut) {
|
|
674
|
+
const message = `route33: no model catalog available \u2014 0 models injected (${reason}). route33 is unusable without a catalog: resolve an API key (opencode auth login / ROUTE33_API_KEY) \u2014 the key dictates which models are available.`;
|
|
675
|
+
if (optOut) logger.info(message);
|
|
676
|
+
else logger.warn(message);
|
|
677
|
+
return { models: {}, source: "none" };
|
|
657
678
|
}
|
|
658
679
|
async function runLadder(opts) {
|
|
659
|
-
const { key, baseURL,
|
|
680
|
+
const { key, baseURL, env = process.env, fetchImpl, now } = opts;
|
|
660
681
|
if (env[ENV_DISCOVERY] === "0")
|
|
661
|
-
return
|
|
662
|
-
if (!key) return
|
|
682
|
+
return noneResult(`discovery disabled via ${ENV_DISCOVERY}=0`, true);
|
|
683
|
+
if (!key) return noneResult("no API key resolved", false);
|
|
663
684
|
const fresh = readFreshCatalog({ key, env, now });
|
|
664
685
|
if (fresh) {
|
|
665
686
|
logger.info(
|
|
@@ -686,280 +707,33 @@ async function runLadder(opts) {
|
|
|
686
707
|
);
|
|
687
708
|
return { models: stale, source: "stale-cache" };
|
|
688
709
|
}
|
|
689
|
-
return
|
|
710
|
+
return noneResult("live fetch failed and no cache available", false);
|
|
690
711
|
}
|
|
691
712
|
async function getCatalog(opts) {
|
|
692
713
|
try {
|
|
693
714
|
return await runLadder(opts);
|
|
694
715
|
} catch (error) {
|
|
695
716
|
logger.error(
|
|
696
|
-
`unexpected catalog-service failure \u2014
|
|
717
|
+
`unexpected catalog-service failure \u2014 no catalog available: ${error instanceof Error ? error.message : String(error)}`
|
|
697
718
|
);
|
|
698
|
-
|
|
699
|
-
return seedResult(opts.seed, "unexpected internal failure");
|
|
700
|
-
} catch {
|
|
701
|
-
return { models: {}, source: "seed" };
|
|
702
|
-
}
|
|
719
|
+
return noneResult("unexpected internal failure", false);
|
|
703
720
|
}
|
|
704
721
|
}
|
|
705
722
|
|
|
706
|
-
// src/catalog/seed/anthropic-gateway-limit.ts
|
|
707
|
-
var ANTHROPIC_GATEWAY_LIMIT = {
|
|
708
|
-
context: 1e6,
|
|
709
|
-
output: 32e3
|
|
710
|
-
};
|
|
711
|
-
|
|
712
|
-
// src/catalog/seed/slug.ts
|
|
713
|
-
var slug = (s) => s;
|
|
714
|
-
|
|
715
|
-
// src/catalog/seed/claude-opus.ts
|
|
716
|
-
var CLAUDE_OPUS = {
|
|
717
|
-
slug: slug("anthropic/claude-opus-4.8"),
|
|
718
|
-
name: "Claude Opus 4.8",
|
|
719
|
-
cost: {
|
|
720
|
-
input: 5,
|
|
721
|
-
// source: models.dev 2026-07-04 (key: anthropic/claude-opus-4-8, dash form)
|
|
722
|
-
output: 25,
|
|
723
|
-
// source: models.dev 2026-07-04
|
|
724
|
-
cache_read: 0.5,
|
|
725
|
-
// source: models.dev 2026-07-04
|
|
726
|
-
cache_write: 6.25
|
|
727
|
-
// source: models.dev 2026-07-04
|
|
728
|
-
},
|
|
729
|
-
limit: ANTHROPIC_GATEWAY_LIMIT,
|
|
730
|
-
capabilities: { reasoning: true }
|
|
731
|
-
};
|
|
732
|
-
|
|
733
|
-
// src/catalog/seed/claude-sonnet.ts
|
|
734
|
-
var CLAUDE_SONNET = {
|
|
735
|
-
slug: slug("anthropic/claude-sonnet-4.6"),
|
|
736
|
-
name: "Claude Sonnet 4.6",
|
|
737
|
-
cost: {
|
|
738
|
-
input: 3,
|
|
739
|
-
// source: models.dev 2026-07-04 (key: anthropic/claude-sonnet-4-6, dash form)
|
|
740
|
-
output: 15,
|
|
741
|
-
// source: models.dev 2026-07-04
|
|
742
|
-
cache_read: 0.3,
|
|
743
|
-
// source: models.dev 2026-07-04
|
|
744
|
-
cache_write: 3.75
|
|
745
|
-
// source: models.dev 2026-07-04
|
|
746
|
-
},
|
|
747
|
-
limit: ANTHROPIC_GATEWAY_LIMIT,
|
|
748
|
-
capabilities: { reasoning: true }
|
|
749
|
-
};
|
|
750
|
-
|
|
751
|
-
// src/catalog/seed/gemini-3-1-pro-preview.ts
|
|
752
|
-
var GEMINI_3_1_PRO_PREVIEW = {
|
|
753
|
-
slug: slug("google/gemini-3.1-pro-preview"),
|
|
754
|
-
name: "Gemini 3.1 Pro Preview",
|
|
755
|
-
cost: {
|
|
756
|
-
input: 2,
|
|
757
|
-
// source: models.dev 2026-07-04
|
|
758
|
-
output: 12,
|
|
759
|
-
// source: models.dev 2026-07-04
|
|
760
|
-
cache_read: 0.2,
|
|
761
|
-
// source: models.dev 2026-07-04
|
|
762
|
-
// models.dev's context tier boundary is exactly 200,000 tokens here —
|
|
763
|
-
// context_over_200k is the lossless encoding (D20 "certainty achieved").
|
|
764
|
-
context_over_200k: {
|
|
765
|
-
input: 4,
|
|
766
|
-
// source: models.dev 2026-07-04
|
|
767
|
-
output: 18,
|
|
768
|
-
// source: models.dev 2026-07-04
|
|
769
|
-
cache_read: 0.4
|
|
770
|
-
// source: models.dev 2026-07-04
|
|
771
|
-
}
|
|
772
|
-
},
|
|
773
|
-
limit: { context: 1048576, output: 65536 },
|
|
774
|
-
// source: models.dev 2026-07-04
|
|
775
|
-
capabilities: {}
|
|
776
|
-
};
|
|
777
|
-
|
|
778
|
-
// src/catalog/seed/glm-5-2.ts
|
|
779
|
-
var GLM_5_2 = {
|
|
780
|
-
slug: slug("z-ai/glm-5.2"),
|
|
781
|
-
name: "GLM-5.2",
|
|
782
|
-
cost: {
|
|
783
|
-
input: 1.4,
|
|
784
|
-
// source: models.dev 2026-07-04 (provider key: zai)
|
|
785
|
-
output: 4.4,
|
|
786
|
-
// source: models.dev 2026-07-04
|
|
787
|
-
cache_read: 0.26
|
|
788
|
-
// source: models.dev 2026-07-04
|
|
789
|
-
// cache_write deliberately OMITTED: models.dev lists a literal 0, which
|
|
790
|
-
// the seed contract forbids as a recorded price. Omission makes opencode
|
|
791
|
-
// bill cache writes at the input rate — the conservative D20 fallback
|
|
792
|
-
// (overestimate over a possibly-wrong free write).
|
|
793
|
-
},
|
|
794
|
-
limit: { context: 1e6, output: 131072 },
|
|
795
|
-
// source: models.dev 2026-07-04
|
|
796
|
-
capabilities: {}
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
// src/catalog/seed/gpt-4o-mini.ts
|
|
800
|
-
var GPT_4O_MINI = {
|
|
801
|
-
slug: slug("openai/gpt-4o-mini"),
|
|
802
|
-
name: "GPT-4o mini",
|
|
803
|
-
cost: {
|
|
804
|
-
input: 0.15,
|
|
805
|
-
// source: models.dev 2026-07-04
|
|
806
|
-
output: 0.6,
|
|
807
|
-
// source: models.dev 2026-07-04
|
|
808
|
-
cache_read: 0.075
|
|
809
|
-
// source: models.dev 2026-07-04
|
|
810
|
-
},
|
|
811
|
-
limit: { context: 128e3, output: 16384 },
|
|
812
|
-
// source: models.dev 2026-07-04
|
|
813
|
-
capabilities: {}
|
|
814
|
-
};
|
|
815
|
-
|
|
816
|
-
// src/catalog/seed/gpt-5-4-mini.ts
|
|
817
|
-
var GPT_5_4_MINI = {
|
|
818
|
-
slug: slug("openai/gpt-5.4-mini"),
|
|
819
|
-
name: "GPT-5.4 mini",
|
|
820
|
-
cost: {
|
|
821
|
-
input: 0.75,
|
|
822
|
-
// source: models.dev 2026-07-04
|
|
823
|
-
output: 4.5,
|
|
824
|
-
// source: models.dev 2026-07-04
|
|
825
|
-
cache_read: 0.075
|
|
826
|
-
// source: models.dev 2026-07-04
|
|
827
|
-
},
|
|
828
|
-
limit: { context: 4e5, output: 128e3 },
|
|
829
|
-
// source: models.dev 2026-07-04
|
|
830
|
-
// Reasoning model; opencode's variants gate (transform.ts) requires the flag.
|
|
831
|
-
capabilities: { reasoning: true }
|
|
832
|
-
};
|
|
833
|
-
|
|
834
|
-
// src/catalog/seed/gpt-5-5.ts
|
|
835
|
-
var GPT_5_5 = {
|
|
836
|
-
slug: slug("openai/gpt-5.5"),
|
|
837
|
-
name: "GPT-5.5",
|
|
838
|
-
cost: {
|
|
839
|
-
input: 5,
|
|
840
|
-
// source: models.dev 2026-07-04
|
|
841
|
-
output: 30,
|
|
842
|
-
// source: models.dev 2026-07-04
|
|
843
|
-
cache_read: 0.5,
|
|
844
|
-
// source: models.dev 2026-07-04
|
|
845
|
-
// models.dev lists a context tier at 272k tokens (input 10 / output 45 /
|
|
846
|
-
// cache_read 1) AND ships this pre-computed context_over_200k with the
|
|
847
|
-
// same rates. The 272k boundary itself is inexpressible in the config
|
|
848
|
-
// schema; billing the higher tier from 200k instead only ever
|
|
849
|
-
// OVER-estimates the 200k–272k window — D20's conservative branch,
|
|
850
|
-
// encoded precisely rather than flattened to a max rate.
|
|
851
|
-
context_over_200k: {
|
|
852
|
-
input: 10,
|
|
853
|
-
// source: models.dev 2026-07-04
|
|
854
|
-
output: 45,
|
|
855
|
-
// source: models.dev 2026-07-04
|
|
856
|
-
cache_read: 1
|
|
857
|
-
// source: models.dev 2026-07-04
|
|
858
|
-
}
|
|
859
|
-
},
|
|
860
|
-
limit: { context: 105e4, output: 128e3 },
|
|
861
|
-
// source: models.dev 2026-07-04
|
|
862
|
-
// Reasoning model; opencode's variants gate (transform.ts) requires the flag.
|
|
863
|
-
capabilities: { reasoning: true }
|
|
864
|
-
};
|
|
865
|
-
|
|
866
|
-
// src/catalog/seed/kimi-k2-6.ts
|
|
867
|
-
var KIMI_K2_6 = {
|
|
868
|
-
slug: slug("moonshotai/kimi-k2.6"),
|
|
869
|
-
name: "Kimi K2.6",
|
|
870
|
-
cost: {
|
|
871
|
-
input: 0.95,
|
|
872
|
-
// source: models.dev 2026-07-04
|
|
873
|
-
output: 4,
|
|
874
|
-
// source: models.dev 2026-07-04
|
|
875
|
-
cache_read: 0.16
|
|
876
|
-
// source: models.dev 2026-07-04
|
|
877
|
-
},
|
|
878
|
-
limit: { context: 262144, output: 262144 },
|
|
879
|
-
// source: models.dev 2026-07-04
|
|
880
|
-
capabilities: {}
|
|
881
|
-
};
|
|
882
|
-
|
|
883
|
-
// src/catalog/seed/kimi-k2-7-code.ts
|
|
884
|
-
var KIMI_K2_7_CODE = {
|
|
885
|
-
slug: slug("moonshotai/kimi-k2.7-code"),
|
|
886
|
-
name: "Kimi K2.7 Code",
|
|
887
|
-
cost: {
|
|
888
|
-
input: 0.95,
|
|
889
|
-
// source: models.dev 2026-07-04
|
|
890
|
-
output: 4,
|
|
891
|
-
// source: models.dev 2026-07-04
|
|
892
|
-
cache_read: 0.19
|
|
893
|
-
// source: models.dev 2026-07-04
|
|
894
|
-
},
|
|
895
|
-
limit: { context: 262144, output: 262144 },
|
|
896
|
-
// source: models.dev 2026-07-04
|
|
897
|
-
capabilities: {}
|
|
898
|
-
};
|
|
899
|
-
|
|
900
|
-
// src/catalog/seed/kimi-k3.ts
|
|
901
|
-
var KIMI_K3 = {
|
|
902
|
-
slug: slug("moonshotai/kimi-k3"),
|
|
903
|
-
name: "Kimi K3",
|
|
904
|
-
cost: {
|
|
905
|
-
input: 3,
|
|
906
|
-
// source: models.dev 2026-07-18
|
|
907
|
-
output: 15,
|
|
908
|
-
// source: models.dev 2026-07-18
|
|
909
|
-
cache_read: 0.3
|
|
910
|
-
// source: models.dev 2026-07-18
|
|
911
|
-
},
|
|
912
|
-
limit: { context: 1048576, output: 131072 },
|
|
913
|
-
// source: models.dev 2026-07-18
|
|
914
|
-
capabilities: { reasoning: true },
|
|
915
|
-
// opencode-go parity: the openai-compatible lowerer wires reasoning_effort: "max".
|
|
916
|
-
variants: { max: { reasoningEffort: "max" } }
|
|
917
|
-
};
|
|
918
|
-
|
|
919
|
-
// src/catalog/seed/seed.ts
|
|
920
|
-
var SEED = [
|
|
921
|
-
GPT_5_5,
|
|
922
|
-
GPT_5_4_MINI,
|
|
923
|
-
GEMINI_3_1_PRO_PREVIEW,
|
|
924
|
-
KIMI_K2_6,
|
|
925
|
-
KIMI_K2_7_CODE,
|
|
926
|
-
KIMI_K3,
|
|
927
|
-
GPT_4O_MINI,
|
|
928
|
-
GLM_5_2,
|
|
929
|
-
CLAUDE_OPUS,
|
|
930
|
-
CLAUDE_SONNET,
|
|
931
|
-
// D10 dash-form aliases — spread from their canonical entries so
|
|
932
|
-
// cost/limit/capabilities stay identical by construction.
|
|
933
|
-
{
|
|
934
|
-
...CLAUDE_OPUS,
|
|
935
|
-
slug: slug("anthropic/claude-opus-4-8"),
|
|
936
|
-
name: `${CLAUDE_OPUS.name} (alias)`,
|
|
937
|
-
aliasOf: CLAUDE_OPUS.slug
|
|
938
|
-
},
|
|
939
|
-
{
|
|
940
|
-
...CLAUDE_SONNET,
|
|
941
|
-
slug: slug("anthropic/claude-sonnet-4-6"),
|
|
942
|
-
name: `${CLAUDE_SONNET.name} (alias)`,
|
|
943
|
-
aliasOf: CLAUDE_SONNET.slug
|
|
944
|
-
}
|
|
945
|
-
];
|
|
946
|
-
|
|
947
|
-
// src/enrich/static-meta.ts
|
|
948
|
-
var lookupSeed = (slug2) => SEED.find((entry) => entry.slug === slug2) ?? SEED.find((entry) => entry.aliasOf === slug2);
|
|
949
|
-
|
|
950
723
|
// src/enrich/enrich-model.ts
|
|
951
|
-
function resolveCost(catalogModel, modelsDevLookup
|
|
724
|
+
function resolveCost(catalogModel, modelsDevLookup) {
|
|
952
725
|
const r33Cost = catalogModel?.pricing !== void 0 ? extractPricing(catalogModel.pricing) : void 0;
|
|
953
|
-
return r33Cost ?? modelsDevLookup?.cost
|
|
726
|
+
return r33Cost ?? modelsDevLookup?.cost;
|
|
954
727
|
}
|
|
955
|
-
function resolveLimit(
|
|
956
|
-
return
|
|
728
|
+
function resolveLimit(catalogModel, modelsDevLookup) {
|
|
729
|
+
return catalogModel?.limit ?? modelsDevLookup?.limit;
|
|
957
730
|
}
|
|
958
|
-
function resolveCapabilities(
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
731
|
+
function resolveCapabilities(catalogModel, modelsDevLookup) {
|
|
732
|
+
const result = {};
|
|
733
|
+
const broadcast = catalogModel?.capabilities;
|
|
734
|
+
if (broadcast?.reasoning !== void 0) result.reasoning = broadcast.reasoning;
|
|
735
|
+
if (broadcast?.tool_call !== void 0) result.tool_call = broadcast.tool_call;
|
|
736
|
+
if (modelsDevLookup === void 0) return result;
|
|
963
737
|
if (result.reasoning === void 0 && modelsDevLookup.reasoning !== void 0)
|
|
964
738
|
result.reasoning = modelsDevLookup.reasoning;
|
|
965
739
|
if (result.tool_call === void 0 && modelsDevLookup.tool_call !== void 0)
|
|
@@ -971,21 +745,25 @@ function resolveCapabilities(seed, modelsDevLookup, env) {
|
|
|
971
745
|
}
|
|
972
746
|
return result;
|
|
973
747
|
}
|
|
974
|
-
function enrichModel(
|
|
975
|
-
const
|
|
976
|
-
const seed = lookupSeed(slug2);
|
|
977
|
-
const modelsDevLookup = sources.modelsDev.lookup(slug2);
|
|
978
|
-
const capabilities = resolveCapabilities(seed, modelsDevLookup, env);
|
|
748
|
+
function enrichModel(slug, sources) {
|
|
749
|
+
const modelsDevLookup = sources.modelsDev.lookup(slug);
|
|
979
750
|
const catalogModel = sources.catalogModel;
|
|
980
|
-
const
|
|
751
|
+
const capabilities = resolveCapabilities(catalogModel, modelsDevLookup);
|
|
752
|
+
const name = catalogModel !== void 0 && catalogModel.id === slug && catalogModel.displayName !== void 0 ? catalogModel.displayName : slug;
|
|
981
753
|
const meta = { name, capabilities };
|
|
982
|
-
const limit = resolveLimit(
|
|
983
|
-
if (limit
|
|
984
|
-
|
|
985
|
-
|
|
754
|
+
const limit = resolveLimit(catalogModel, modelsDevLookup);
|
|
755
|
+
if (limit === void 0) {
|
|
756
|
+
logger.warn(
|
|
757
|
+
`limit metadata unavailable for ${slug} \u2014 no context/output ceiling will be enforced until added`
|
|
758
|
+
);
|
|
759
|
+
} else {
|
|
760
|
+
meta.limit = limit;
|
|
761
|
+
}
|
|
762
|
+
if (catalogModel?.variants !== void 0) meta.variants = catalogModel.variants;
|
|
763
|
+
const cost = resolveCost(catalogModel, modelsDevLookup);
|
|
986
764
|
if (cost === void 0) {
|
|
987
765
|
logger.warn(
|
|
988
|
-
`cost metadata unavailable for ${
|
|
766
|
+
`cost metadata unavailable for ${slug} \u2014 reported cost will be $0 until added`
|
|
989
767
|
);
|
|
990
768
|
return meta;
|
|
991
769
|
}
|
|
@@ -1095,11 +873,11 @@ function swapLast(value, from, to) {
|
|
|
1095
873
|
const index = value.lastIndexOf(from);
|
|
1096
874
|
return index === -1 ? value : `${value.slice(0, index)}${to}${value.slice(index + 1)}`;
|
|
1097
875
|
}
|
|
1098
|
-
function probeCandidates(
|
|
1099
|
-
const sep =
|
|
1100
|
-
if (sep <= 0 || sep >=
|
|
1101
|
-
const vendor =
|
|
1102
|
-
const model =
|
|
876
|
+
function probeCandidates(slug) {
|
|
877
|
+
const sep = slug.indexOf("/");
|
|
878
|
+
if (sep <= 0 || sep >= slug.length - 1) return [];
|
|
879
|
+
const vendor = slug.slice(0, sep);
|
|
880
|
+
const model = slug.slice(sep + 1);
|
|
1103
881
|
const vendors = [vendor];
|
|
1104
882
|
const alias = VENDOR_ALIASES[vendor];
|
|
1105
883
|
if (alias !== void 0 && alias !== vendor) vendors.push(alias);
|
|
@@ -1150,8 +928,8 @@ function buildLookup(model) {
|
|
|
1150
928
|
}
|
|
1151
929
|
return result;
|
|
1152
930
|
}
|
|
1153
|
-
function lookupSlug(data,
|
|
1154
|
-
for (const [providerKey, modelKey] of probeCandidates(
|
|
931
|
+
function lookupSlug(data, slug) {
|
|
932
|
+
for (const [providerKey, modelKey] of probeCandidates(slug)) {
|
|
1155
933
|
const provider = data[providerKey];
|
|
1156
934
|
if (!isRecord6(provider) || !isRecord6(provider.models)) continue;
|
|
1157
935
|
const model = provider.models[modelKey];
|
|
@@ -1182,7 +960,7 @@ function writeCache(cachePath, envelope) {
|
|
|
1182
960
|
}
|
|
1183
961
|
}
|
|
1184
962
|
function makeSource(source, data) {
|
|
1185
|
-
return { source, lookup: (
|
|
963
|
+
return { source, lookup: (slug) => lookupSlug(data, slug) };
|
|
1186
964
|
}
|
|
1187
965
|
async function loadModelsDev(options = {}) {
|
|
1188
966
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -1208,65 +986,16 @@ async function loadModelsDev(options = {}) {
|
|
|
1208
986
|
return { source: null, lookup: () => void 0 };
|
|
1209
987
|
}
|
|
1210
988
|
|
|
1211
|
-
// src/config/build-alias-targets.ts
|
|
1212
|
-
var SEED_ALIAS_TARGETS = Object.fromEntries(
|
|
1213
|
-
SEED.flatMap(
|
|
1214
|
-
(entry) => entry.aliasOf === void 0 ? [] : [[entry.slug, entry.aliasOf]]
|
|
1215
|
-
)
|
|
1216
|
-
);
|
|
1217
|
-
function buildAliasTargets(catalog) {
|
|
1218
|
-
const targets = { ...SEED_ALIAS_TARGETS };
|
|
1219
|
-
if (catalog.source === "seed") return targets;
|
|
1220
|
-
for (const entry of SEED) {
|
|
1221
|
-
if (entry.aliasOf === void 0) continue;
|
|
1222
|
-
const aliasSlug = entry.slug;
|
|
1223
|
-
const canonicalSlug = entry.aliasOf;
|
|
1224
|
-
const aliasPresent = catalog.models[aliasSlug] !== void 0;
|
|
1225
|
-
const canonicalPresent = catalog.models[canonicalSlug] !== void 0;
|
|
1226
|
-
if (aliasPresent && !canonicalPresent) {
|
|
1227
|
-
targets[canonicalSlug] = aliasSlug;
|
|
1228
|
-
delete targets[aliasSlug];
|
|
1229
|
-
} else if (aliasPresent && canonicalPresent) {
|
|
1230
|
-
delete targets[aliasSlug];
|
|
1231
|
-
delete targets[canonicalSlug];
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
return targets;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
989
|
// src/config/enrich-catalog.ts
|
|
1238
|
-
function enrichOne(
|
|
990
|
+
function enrichOne(slug, catalogModel, modelsDev, env) {
|
|
1239
991
|
const sources = { modelsDev, env };
|
|
1240
992
|
if (catalogModel !== void 0) sources.catalogModel = catalogModel;
|
|
1241
|
-
return enrichModel(
|
|
993
|
+
return enrichModel(slug, sources);
|
|
1242
994
|
}
|
|
1243
995
|
function enrichCatalog(catalog, modelsDev, env) {
|
|
1244
996
|
const enriched = {};
|
|
1245
|
-
for (const
|
|
1246
|
-
enriched[
|
|
1247
|
-
}
|
|
1248
|
-
for (const entry of SEED) {
|
|
1249
|
-
if (entry.aliasOf === void 0) continue;
|
|
1250
|
-
const aliasSlug = entry.slug;
|
|
1251
|
-
const canonicalSlug = entry.aliasOf;
|
|
1252
|
-
const aliasPresent = enriched[aliasSlug] !== void 0;
|
|
1253
|
-
const canonicalPresent = enriched[canonicalSlug] !== void 0;
|
|
1254
|
-
if (aliasPresent === canonicalPresent) continue;
|
|
1255
|
-
if (canonicalPresent) {
|
|
1256
|
-
enriched[aliasSlug] = enrichOne(
|
|
1257
|
-
aliasSlug,
|
|
1258
|
-
catalog.models[canonicalSlug],
|
|
1259
|
-
modelsDev,
|
|
1260
|
-
env
|
|
1261
|
-
);
|
|
1262
|
-
} else {
|
|
1263
|
-
enriched[canonicalSlug] = enrichOne(
|
|
1264
|
-
canonicalSlug,
|
|
1265
|
-
catalog.models[aliasSlug],
|
|
1266
|
-
modelsDev,
|
|
1267
|
-
env
|
|
1268
|
-
);
|
|
1269
|
-
}
|
|
997
|
+
for (const slug of Object.keys(catalog.models)) {
|
|
998
|
+
enriched[slug] = enrichOne(slug, catalog.models[slug], modelsDev, env);
|
|
1270
999
|
}
|
|
1271
1000
|
return enriched;
|
|
1272
1001
|
}
|
|
@@ -1475,6 +1204,26 @@ function findMissingRefs(cfg, refs) {
|
|
|
1475
1204
|
return [...byRef.values()].sort((a, b) => a.ref.localeCompare(b.ref));
|
|
1476
1205
|
}
|
|
1477
1206
|
|
|
1207
|
+
// src/config/inject-aliased.ts
|
|
1208
|
+
function deepClone(value) {
|
|
1209
|
+
if (Array.isArray(value)) return value.map((item) => deepClone(item));
|
|
1210
|
+
if (typeof value === "object" && value !== null) {
|
|
1211
|
+
return Object.fromEntries(
|
|
1212
|
+
Object.entries(value).map(([key, field]) => [key, deepClone(field)])
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
return value;
|
|
1216
|
+
}
|
|
1217
|
+
function injectAliased(cfg, missing, servedKey) {
|
|
1218
|
+
const models = cfg.provider?.[missing.providerId]?.models;
|
|
1219
|
+
if (models === void 0) return;
|
|
1220
|
+
const served = models[servedKey];
|
|
1221
|
+
if (typeof served !== "object" || served === null) return;
|
|
1222
|
+
const entry = deepClone(served);
|
|
1223
|
+
entry.id = servedKey;
|
|
1224
|
+
models[missing.modelKey] = entry;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1478
1227
|
// src/config/inject-stubs.ts
|
|
1479
1228
|
function injectStubs(cfg, missing) {
|
|
1480
1229
|
if (cfg.provider === void 0) cfg.provider = {};
|
|
@@ -1487,6 +1236,40 @@ function injectStubs(cfg, missing) {
|
|
|
1487
1236
|
}
|
|
1488
1237
|
}
|
|
1489
1238
|
|
|
1239
|
+
// src/config/resolve-spelling.ts
|
|
1240
|
+
function isDigit(char) {
|
|
1241
|
+
return char !== void 0 && char >= "0" && char <= "9";
|
|
1242
|
+
}
|
|
1243
|
+
function swapAtDigitBoundaries(tail, from, to) {
|
|
1244
|
+
let out = "";
|
|
1245
|
+
for (let i = 0; i < tail.length; i++) {
|
|
1246
|
+
const char = tail[i];
|
|
1247
|
+
out += char === from && isDigit(tail[i - 1]) && isDigit(tail[i + 1]) ? to : char;
|
|
1248
|
+
}
|
|
1249
|
+
return out;
|
|
1250
|
+
}
|
|
1251
|
+
function resolveSpelling(modelKey, availableKeys2) {
|
|
1252
|
+
const sep = modelKey.indexOf("/");
|
|
1253
|
+
const vendor = sep === -1 ? "" : modelKey.slice(0, sep);
|
|
1254
|
+
const tail = sep === -1 ? modelKey : modelKey.slice(sep + 1);
|
|
1255
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1256
|
+
for (const swapped of [
|
|
1257
|
+
swapAtDigitBoundaries(tail, ".", "-"),
|
|
1258
|
+
swapAtDigitBoundaries(tail, "-", ".")
|
|
1259
|
+
]) {
|
|
1260
|
+
if (swapped !== tail) candidates.add(swapped);
|
|
1261
|
+
}
|
|
1262
|
+
if (candidates.size === 0) return void 0;
|
|
1263
|
+
const matches = [];
|
|
1264
|
+
for (const key of availableKeys2) {
|
|
1265
|
+
const keySep = key.indexOf("/");
|
|
1266
|
+
const keyVendor = keySep === -1 ? "" : key.slice(0, keySep);
|
|
1267
|
+
const keyTail = keySep === -1 ? key : key.slice(keySep + 1);
|
|
1268
|
+
if (keyVendor === vendor && candidates.has(keyTail)) matches.push(key);
|
|
1269
|
+
}
|
|
1270
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1490
1273
|
// src/config/validation-error.ts
|
|
1491
1274
|
var Route33ValidationError = class extends Error {
|
|
1492
1275
|
missingRefs;
|
|
@@ -1510,9 +1293,25 @@ function validateRefs(cfg, options = {}) {
|
|
|
1510
1293
|
const refs = [...collectCfgRefs(cfg), ...collectFlavourRefs(env, cwd)];
|
|
1511
1294
|
const missing = findMissingRefs(cfg, refs);
|
|
1512
1295
|
if (missing.length === 0) return;
|
|
1513
|
-
const
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1296
|
+
const unresolved = [];
|
|
1297
|
+
for (const entry of missing) {
|
|
1298
|
+
const servedKey = entry.legacyReplacement === void 0 ? resolveSpelling(
|
|
1299
|
+
entry.modelKey,
|
|
1300
|
+
Object.keys(cfg.provider?.[entry.providerId]?.models ?? {})
|
|
1301
|
+
) : void 0;
|
|
1302
|
+
if (servedKey === void 0) {
|
|
1303
|
+
unresolved.push(entry);
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
injectAliased(cfg, entry, servedKey);
|
|
1307
|
+
logger.info(
|
|
1308
|
+
`${entry.ref} resolved to the served spelling "${entry.providerId}/${servedKey}" via dot\u2194dash tolerance \u2014 injected an aliased model entry routing to the served id`
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
if (unresolved.length === 0) return;
|
|
1312
|
+
const details = describeMissing(cfg, unresolved);
|
|
1313
|
+
const missingIds = unresolved.map((entry) => entry.ref);
|
|
1314
|
+
if (unresolved.some((entry) => entry.legacyReplacement !== void 0)) {
|
|
1516
1315
|
throw new Route33ValidationError(details, missingIds);
|
|
1517
1316
|
}
|
|
1518
1317
|
if (env[ENV_STRICT] === "0") {
|
|
@@ -1521,14 +1320,21 @@ function validateRefs(cfg, options = {}) {
|
|
|
1521
1320
|
|
|
1522
1321
|
(${ENV_STRICT}=0 \u2014 continuing with name-only stub entries)`
|
|
1523
1322
|
);
|
|
1524
|
-
injectStubs(cfg,
|
|
1323
|
+
injectStubs(cfg, unresolved);
|
|
1525
1324
|
return;
|
|
1526
1325
|
}
|
|
1527
1326
|
if (options.keyResolved === false) {
|
|
1528
1327
|
logger.warn(
|
|
1529
|
-
`no route_33 API key resolved \u2014 catalog
|
|
1328
|
+
`no route_33 API key resolved \u2014 no model catalog available; ${unresolved.length} referenced model(s) are unavailable until a key is configured: ${missingIds.join(", ")}. Enter a key via the project-key prompt, \`opencode auth login\`, ${ENV_API_KEY}, or a provider.route_33.options.apiKey literal, then restart. (Continuing with name-only stub entries.)`
|
|
1530
1329
|
);
|
|
1531
|
-
injectStubs(cfg,
|
|
1330
|
+
injectStubs(cfg, unresolved);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (options.catalogAvailable === false) {
|
|
1334
|
+
logger.warn(
|
|
1335
|
+
`route33 model catalog unavailable \u2014 ${unresolved.length} referenced model(s) could not be validated against the key-scoped catalog: ${missingIds.join(", ")}. Restore gateway connectivity (a warm cache is reused automatically) or declare the model explicitly under "provider" in opencode.jsonc, then restart. (Continuing with name-only stub entries.)`
|
|
1336
|
+
);
|
|
1337
|
+
injectStubs(cfg, unresolved);
|
|
1532
1338
|
return;
|
|
1533
1339
|
}
|
|
1534
1340
|
throw new Route33ValidationError(`${details}
|
|
@@ -1546,16 +1352,17 @@ function createConfigHook(options = {}) {
|
|
|
1546
1352
|
const fetchImpl = options.fetchImpl;
|
|
1547
1353
|
return async (cfg) => {
|
|
1548
1354
|
let key;
|
|
1355
|
+
let catalogAvailable = false;
|
|
1549
1356
|
try {
|
|
1550
1357
|
key = resolveKey(cfg, env, cwd);
|
|
1551
1358
|
const baseURL = resolveBaseUrl(cfg, env, cwd);
|
|
1552
1359
|
const catalog = await getCatalog({
|
|
1553
1360
|
key,
|
|
1554
1361
|
baseURL,
|
|
1555
|
-
seed: SEED,
|
|
1556
1362
|
env,
|
|
1557
1363
|
fetchImpl
|
|
1558
1364
|
});
|
|
1365
|
+
catalogAvailable = catalog.source !== "none";
|
|
1559
1366
|
const modelsDevOptions = {
|
|
1560
1367
|
cacheDir: resolveCacheDir(env)
|
|
1561
1368
|
};
|
|
@@ -1565,7 +1372,6 @@ function createConfigHook(options = {}) {
|
|
|
1565
1372
|
const blocks = buildProviderBlocks(enriched, {
|
|
1566
1373
|
apiKey: key,
|
|
1567
1374
|
baseURL,
|
|
1568
|
-
aliasTargets: buildAliasTargets(catalog),
|
|
1569
1375
|
// Per-request resolution for the live-auth fetch: a prompt-entered
|
|
1570
1376
|
// key applies to the very next request, no restart. The static
|
|
1571
1377
|
// apiKey above stays the boot-time/catalog path.
|
|
@@ -1578,7 +1384,12 @@ function createConfigHook(options = {}) {
|
|
|
1578
1384
|
);
|
|
1579
1385
|
}
|
|
1580
1386
|
try {
|
|
1581
|
-
validateRefs(cfg, {
|
|
1387
|
+
validateRefs(cfg, {
|
|
1388
|
+
env,
|
|
1389
|
+
cwd,
|
|
1390
|
+
keyResolved: key !== void 0,
|
|
1391
|
+
catalogAvailable
|
|
1392
|
+
});
|
|
1582
1393
|
} catch (error) {
|
|
1583
1394
|
if (error instanceof Route33ValidationError) {
|
|
1584
1395
|
logger.error(error.message);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Gateway limits and variants broadcast proposal
|
|
2
|
+
|
|
3
|
+
**To:** R33 gateway team
|
|
4
|
+
**From:** opencode-route33 plugin maintainers
|
|
5
|
+
**Date:** 2026-08-10
|
|
6
|
+
**Subject:** Embedding per-model limits and variants in `GET /v1/models`
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
This document is the limits and variants sibling of the [gateway pricing broadcast proposal](./gateway-pricing-proposal.md) from 2026-07-07. That contract shipped and proved that extra JSON fields on the standard OpenAI-compatible `/v1/models` response do not break clients.
|
|
11
|
+
|
|
12
|
+
The opencode-route33 plugin previously kept a static seed catalog with hardcoded `context_window`, `max_output_tokens`, capability flags, and variant definitions. That file has been deleted. The gateway already persists `context_window` and `max_output_tokens` on `canonical_models`, and the catalog now needs to treat variants as platform-curated data (for example, `kimi-k3`'s `max` variant that sets `reasoningEffort: "max"`). Without a broadcast contract, plugin consumers fall back to stale seed data or fabricated defaults, which violates the never-accept-loss principle for model metadata.
|
|
13
|
+
|
|
14
|
+
This creates three gaps:
|
|
15
|
+
|
|
16
|
+
1. **Stale or fabricated limits.** The plugin has no source of truth for token ceilings once the seed catalog is gone.
|
|
17
|
+
2. **Variants out of sync.** Model-specific parameter variants (max reasoning effort, output style presets, and so on) must live with the curated catalog, not in a plugin-side JSON file.
|
|
18
|
+
3. **New-model lag.** A model that appears in the gateway catalog without published limit/variant metadata is treated as a lossy fallback.
|
|
19
|
+
|
|
20
|
+
## Recommendation
|
|
21
|
+
|
|
22
|
+
Extend each entry of the existing `GET /v1/models` response with two new optional fields:
|
|
23
|
+
|
|
24
|
+
- `limits`: `{ "context": number, "output": number }`, present only when both values are positive integers.
|
|
25
|
+
- `variants`: `Record<string, Record<string, unknown>>`, passed through verbatim from the platform's curated catalog.
|
|
26
|
+
|
|
27
|
+
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
|
+
|
|
29
|
+
### Concrete JSON example
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"object": "list",
|
|
34
|
+
"data": [
|
|
35
|
+
{
|
|
36
|
+
"id": "anthropic/claude-opus-4-8",
|
|
37
|
+
"object": "model",
|
|
38
|
+
"created": 1767225600,
|
|
39
|
+
"owned_by": "anthropic",
|
|
40
|
+
"display_name": "Claude Opus 4.8",
|
|
41
|
+
"pricing": {
|
|
42
|
+
"input": 5,
|
|
43
|
+
"output": 25,
|
|
44
|
+
"cache_read": 0.5,
|
|
45
|
+
"cache_write": 6.25
|
|
46
|
+
},
|
|
47
|
+
"capabilities": {
|
|
48
|
+
"modality": "text",
|
|
49
|
+
"context_tier": "large",
|
|
50
|
+
"tool_support": true,
|
|
51
|
+
"structured_output": true,
|
|
52
|
+
"generation_class": "chat",
|
|
53
|
+
"reasoning": true
|
|
54
|
+
},
|
|
55
|
+
"endpoints": ["chat", "messages"],
|
|
56
|
+
"limits": {
|
|
57
|
+
"context": 1000000,
|
|
58
|
+
"output": 128000
|
|
59
|
+
},
|
|
60
|
+
"variants": {
|
|
61
|
+
"max": {
|
|
62
|
+
"reasoningEffort": "max"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Field semantics
|
|
71
|
+
|
|
72
|
+
| Field | Type | Meaning |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `id` | string | Canonical model identifier in `vendor/slug` form |
|
|
75
|
+
| `object` | string | Always `"model"` |
|
|
76
|
+
| `created` | integer | Unix epoch timestamp when the model was added to the catalog |
|
|
77
|
+
| `owned_by` | string | Model vendor, matching the `vendor` portion of `id` |
|
|
78
|
+
| `display_name` | string or omitted | Human-readable model label |
|
|
79
|
+
| `pricing` | object or omitted | Pricing object as defined in the 2026-07-07 pricing broadcast |
|
|
80
|
+
| `capabilities` | object or omitted | Structured capability flags |
|
|
81
|
+
| `capabilities.modality` | string | Input/output modality, for example `"text"` |
|
|
82
|
+
| `capabilities.context_tier` | string | Qualitative size class, for example `"large"` |
|
|
83
|
+
| `capabilities.tool_support` | boolean | Native tool calling; mapped to opencode `tool_call` when `true` |
|
|
84
|
+
| `capabilities.structured_output` | boolean | Native structured / JSON mode support |
|
|
85
|
+
| `capabilities.generation_class` | string | Interaction style, for example `"chat"` |
|
|
86
|
+
| `capabilities.reasoning` | boolean | Exposes reasoning or extended thinking; mapped to opencode `reasoning` when `true` |
|
|
87
|
+
| `endpoints` | array of strings or omitted | Supported gateway endpoints, for example `["chat", "messages"]` |
|
|
88
|
+
| `limits` | object or omitted | Per-model token limits |
|
|
89
|
+
| `limits.context` | positive integer | Maximum context-window tokens |
|
|
90
|
+
| `limits.output` | positive integer | Maximum completion tokens |
|
|
91
|
+
| `variants` | object or omitted | Per-model parameter variants, a record of records |
|
|
92
|
+
|
|
93
|
+
### Consumer parse rules
|
|
94
|
+
|
|
95
|
+
The opencode-route33 plugin consumes this shape with the following rules:
|
|
96
|
+
|
|
97
|
+
- `limits` is accepted only when both `context` and `output` are present and are positive integers. Partial or invalid `limits` objects are ignored. This mirrors the models.dev `normalizeLimit` rule.
|
|
98
|
+
- `capabilities.reasoning` with the boolean value `true` maps to opencode's `reasoning` flag. Any non-boolean value is skipped.
|
|
99
|
+
- `capabilities.tool_support` with the boolean value `true` maps to opencode's `tool_call` flag. Any non-boolean value is skipped.
|
|
100
|
+
- `variants` is accepted only when it is a `Record<string, Record<string, unknown>>` and is passed through verbatim. Malformed variants are skipped.
|
|
101
|
+
- Unknown or malformed fields are ignored. Parsing is never fatal.
|
|
102
|
+
|
|
103
|
+
### Why embed limits and variants on `/v1/models`
|
|
104
|
+
|
|
105
|
+
- **Single round trip.** The plugin fetches the catalog anyway, so embedding removes a second request for limit and variant metadata.
|
|
106
|
+
- **No client breakage.** OpenAI-compatible consumers ignore unknown keys, so existing integrations keep working exactly as before.
|
|
107
|
+
- **Curator is the source of truth.** The platform owns `canonical_models.context_window`, `canonical_models.max_output_tokens`, and the JSONB variants column, so the broadcast comes from the same place the gateway already trusts.
|
|
108
|
+
- **No unit conversion or schema drift.** `limits.context` and `limits.output` are plain token counts, identical to opencode's internal limit schema, and `variants` is a passthrough of the platform JSONB value.
|
|
109
|
+
|
|
110
|
+
## Curated-limits audit reference (models.dev parity, verified 2026-08-10)
|
|
111
|
+
|
|
112
|
+
These entries were cross-checked against models.dev on 2026-08-10 and should be the values broadcast by the gateway:
|
|
113
|
+
|
|
114
|
+
- Anthropic `claude-opus-4-8`, `claude-sonnet-5`, `claude-fable-5`: `1,000,000` context / `128,000` output
|
|
115
|
+
- Anthropic `claude-haiku-4-5`: `200,000` context / `64,000` output
|
|
116
|
+
- `moonshotai/kimi-k3`: `1,048,576` context / `131,072` output, with the `max` variant (`reasoningEffort: "max"`)
|
|
117
|
+
- `minimax/minimax-m3`, `nvidia/nemotron-3-ultra`, `mistralai/mistral-medium-3.5`, and `qwen/qwen3.7-plus` keep their curated values (none of these appear in models.dev, so platform values stand).
|
|
118
|
+
|
|
119
|
+
## Alternative: separate metadata endpoint
|
|
120
|
+
|
|
121
|
+
If the gateway team prefers not to extend the standard `/v1/models` shape, a separate endpoint is acceptable:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
GET /v1/models/metadata
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
It should return the same per-model fields and types documented above (`id`, `object`, `created`, `owned_by`, optional `display_name`, `pricing`, `capabilities`, `endpoints`, `limits`, and `variants`). The plugin would fetch this endpoint and merge the additional metadata with the catalog. The parse rules remain identical, only the HTTP envelope changes.
|
|
128
|
+
|
|
129
|
+
## Secondary option (later)
|
|
130
|
+
|
|
131
|
+
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.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Gateway pricing broadcast proposal
|
|
2
|
+
|
|
3
|
+
**To:** R33 gateway team
|
|
4
|
+
**From:** opencode-route33 plugin maintainers
|
|
5
|
+
**Date:** 2026-07-07 (rev 2 — switched to the `input`/`output` per-1M vocabulary)
|
|
6
|
+
**Subject:** Embedding per-model pricing in `GET /v1/models`
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
The route33.ai gateway exposes `GET /v1/models` for key-scoped catalog discovery, but the response carries no pricing metadata today. Downstream consumers (the opencode-route33 plugin, bench cost aggregation, and user-facing cost estimates) must source prices from an external registry (models.dev) and a hard-coded seed catalog. This creates three gaps:
|
|
11
|
+
|
|
12
|
+
1. **Stale prices** — models.dev is community-maintained and can lag behind gateway rate changes.
|
|
13
|
+
2. **Key-scoped pricing mismatch** — the gateway may offer tiered or negotiated rates per key; a global registry cannot express that.
|
|
14
|
+
3. **New-model lag** — a model that appears in the gateway catalog but not in models.dev reports $0 cost until someone manually adds it to the seed.
|
|
15
|
+
|
|
16
|
+
## Recommendation
|
|
17
|
+
|
|
18
|
+
Embed a `pricing` object inside each entry of the existing `GET /v1/models` response, using the models.dev vocabulary: `input`, `output`, `cache_read`, `cache_write` as **numeric USD per 1M tokens**. Extra JSON fields do not break OpenAI-compatible clients, and key-scoped pricing rides the same key-scoped catalog response (one round trip, atomic catalog-plus-pricing consistency).
|
|
19
|
+
|
|
20
|
+
### Concrete JSON example
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"object": "list",
|
|
25
|
+
"data": [
|
|
26
|
+
{
|
|
27
|
+
"id": "openai/gpt-5.5",
|
|
28
|
+
"object": "model",
|
|
29
|
+
"created": 1751234567,
|
|
30
|
+
"owned_by": "openai",
|
|
31
|
+
"pricing": {
|
|
32
|
+
"input": 5.0,
|
|
33
|
+
"output": 30.0,
|
|
34
|
+
"cache_read": 0.5,
|
|
35
|
+
"cache_write": 1.0
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "anthropic/claude-opus-4-8",
|
|
40
|
+
"object": "model",
|
|
41
|
+
"created": 1751234568,
|
|
42
|
+
"owned_by": "anthropic",
|
|
43
|
+
"pricing": {
|
|
44
|
+
"input": 5.0,
|
|
45
|
+
"output": 25.0,
|
|
46
|
+
"cache_read": 0.5,
|
|
47
|
+
"cache_write": 6.25
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Field semantics** (numeric USD **per 1M tokens**):
|
|
55
|
+
|
|
56
|
+
| Field | Meaning |
|
|
57
|
+
| ------------- | ----------------- |
|
|
58
|
+
| `input` | Input token rate |
|
|
59
|
+
| `output` | Output token rate |
|
|
60
|
+
| `cache_read` | Cache read rate |
|
|
61
|
+
| `cache_write` | Cache write rate |
|
|
62
|
+
|
|
63
|
+
All values are JSON numbers representing the cost of **one million tokens** in USD. `input` and `output` are required for a pricing entry to be consumed; the cache fields are optional.
|
|
64
|
+
|
|
65
|
+
### Why `input`/`output` per-1M numbers
|
|
66
|
+
|
|
67
|
+
- **Terminology match** — `input`/`output` is route33's own token vocabulary, and the modern industry direction (OpenAI's Responses API and Anthropic's Messages API both report `input_tokens`/`output_tokens`).
|
|
68
|
+
- **Consumer match** — this is byte-identical to opencode's native cost schema and the models.dev registry vocabulary, so the opencode-route33 plugin passes values through with **zero unit conversion**.
|
|
69
|
+
- **Human-scale numbers** — `5.0` per 1M reads and reviews better than `"0.000005"` per token, and sidesteps the ecosystem trap of conflating per-token with per-1M values, which has caused real underpricing bugs in other provider plugins.
|
|
70
|
+
|
|
71
|
+
### One vocabulary only (do not mix)
|
|
72
|
+
|
|
73
|
+
The plugin also recognizes the OpenRouter vocabulary (`prompt`, `completion`, `input_cache_read`, `input_cache_write` as string USD **per token**) as an accepted alternative shape. **Pick exactly one vocabulary.** A payload mixing keys from both sets is ambiguous about units and is deliberately ignored by the plugin.
|
|
74
|
+
|
|
75
|
+
### Alternative: byte-pure OpenAI surface
|
|
76
|
+
|
|
77
|
+
If the gateway team prefers not to extend the standard `/v1/models` shape, a separate endpoint is acceptable:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
GET /v1/models/pricing
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
With the **same vocabulary** (`input`, `output`, `cache_read`, `cache_write` as numeric USD-per-1M fields) and the same key-scoped semantics. The plugin would fetch this endpoint alongside the catalog and merge the pricing layer.
|
|
84
|
+
|
|
85
|
+
## Zero plugin changes needed
|
|
86
|
+
|
|
87
|
+
The opencode-route33 plugin already ships a **dual-shape pricing extractor** (decision D14) that is dormant today and will activate automatically the moment recognizable pricing fields appear:
|
|
88
|
+
|
|
89
|
+
- **models.dev vocabulary** (numeric USD per 1M) → passed through unchanged. **(recommended)**
|
|
90
|
+
- **OpenRouter vocabulary** (string USD per token) → converted `×1,000,000` to opencode's per-1M numbers.
|
|
91
|
+
|
|
92
|
+
Adopting either the embedded or the separate-endpoint contract above requires **zero changes** to the plugin — the dormant path becomes active without a version bump.
|
|
93
|
+
|
|
94
|
+
## Secondary option (later)
|
|
95
|
+
|
|
96
|
+
Register route33 in models.dev via a community TOML PR. This is useful for non-gateway consumers, but it does not solve the key-scoped-pricing or stale-data problems. Recommended as a follow-up, not a substitute.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@route_33/opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"main": "./dist/index.js",
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
33
|
"files": [
|
|
34
|
-
"dist"
|
|
34
|
+
"dist",
|
|
35
|
+
"docs"
|
|
35
36
|
],
|
|
36
37
|
"publishConfig": {
|
|
37
38
|
"access": "public"
|