@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 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@^0.2"]
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@^0.2"]
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(slug2) {
350
- return slug2.slice(0, slug2.indexOf("/"));
346
+ function vendorOf(slug) {
347
+ return slug.slice(0, slug.indexOf("/"));
351
348
  }
352
349
  function buildProviderBlocks(enriched, options) {
353
- const { apiKey, baseURL, aliasTargets } = options;
350
+ const { apiKey, baseURL } = options;
354
351
  const resolveKeyNow = options.resolveKeyNow ?? (() => void 0);
355
352
  const route33Models = {};
356
- for (const slug2 of Object.keys(enriched).sort()) {
357
- const meta = enriched[slug2];
353
+ for (const slug of Object.keys(enriched).sort()) {
354
+ const meta = enriched[slug];
358
355
  if (meta === void 0) continue;
359
- const aliasTarget = aliasTargets?.[slug2];
360
- const vendor = vendorOf(slug2);
356
+ const vendor = vendorOf(slug);
361
357
  const nativeNpm = NATIVE_SDK_BY_VENDOR[vendor];
362
- route33Models[slug2] = toModelPatch(
358
+ route33Models[slug] = toModelPatch(
363
359
  meta,
364
- aliasTarget,
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 slug2 = normalizeId(entry.id, env);
584
- if (!slug2 || models[slug2]) continue;
585
- const model = { id: slug2 };
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
- models[slug2] = model;
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 seedResult(seed, why) {
651
- const models = {};
652
- for (const entry of seed) {
653
- models[entry.slug] = { id: entry.slug };
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, seed, env = process.env, fetchImpl, now } = opts;
680
+ const { key, baseURL, env = process.env, fetchImpl, now } = opts;
660
681
  if (env[ENV_DISCOVERY] === "0")
661
- return seedResult(seed, `discovery disabled via ${ENV_DISCOVERY}=0`);
662
- if (!key) return seedResult(seed, "no API key resolved");
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 seedResult(seed, "live fetch failed and no cache available");
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 degrading to seed: ${error instanceof Error ? error.message : String(error)}`
717
+ `unexpected catalog-service failure \u2014 no catalog available: ${error instanceof Error ? error.message : String(error)}`
697
718
  );
698
- try {
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, seed) {
724
+ function resolveCost(catalogModel, modelsDevLookup) {
952
725
  const r33Cost = catalogModel?.pricing !== void 0 ? extractPricing(catalogModel.pricing) : void 0;
953
- return r33Cost ?? modelsDevLookup?.cost ?? seed?.cost;
726
+ return r33Cost ?? modelsDevLookup?.cost;
954
727
  }
955
- function resolveLimit(seed, modelsDevLookup) {
956
- return seed?.limit ?? modelsDevLookup?.limit;
728
+ function resolveLimit(catalogModel, modelsDevLookup) {
729
+ return catalogModel?.limit ?? modelsDevLookup?.limit;
957
730
  }
958
- function resolveCapabilities(seed, modelsDevLookup, env) {
959
- const base = seed?.capabilities ?? {};
960
- if (env[ENV_ENRICH_CAPS] !== "1" || modelsDevLookup === void 0)
961
- return base;
962
- const result = { ...base };
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(slug2, sources) {
975
- const env = sources.env ?? process.env;
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 name = catalogModel !== void 0 && catalogModel.id === slug2 && catalogModel.displayName !== void 0 ? catalogModel.displayName : slug2;
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(seed, modelsDevLookup);
983
- if (limit !== void 0) meta.limit = limit;
984
- if (seed?.variants !== void 0) meta.variants = seed.variants;
985
- const cost = resolveCost(sources.catalogModel, modelsDevLookup, seed);
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 ${slug2} \u2014 reported cost will be $0 until added`
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(slug2) {
1099
- const sep = slug2.indexOf("/");
1100
- if (sep <= 0 || sep >= slug2.length - 1) return [];
1101
- const vendor = slug2.slice(0, sep);
1102
- const model = slug2.slice(sep + 1);
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, slug2) {
1154
- for (const [providerKey, modelKey] of probeCandidates(slug2)) {
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: (slug2) => lookupSlug(data, slug2) };
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(slug2, catalogModel, modelsDev, env) {
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(slug2, sources);
993
+ return enrichModel(slug, sources);
1242
994
  }
1243
995
  function enrichCatalog(catalog, modelsDev, env) {
1244
996
  const enriched = {};
1245
- for (const slug2 of Object.keys(catalog.models)) {
1246
- enriched[slug2] = enrichOne(slug2, catalog.models[slug2], modelsDev, env);
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 details = describeMissing(cfg, missing);
1514
- const missingIds = missing.map((entry) => entry.ref);
1515
- if (missing.some((entry) => entry.legacyReplacement !== void 0)) {
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, missing);
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 is seed-only; ${missing.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.)`
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, missing);
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, { env, cwd, keyResolved: key !== void 0 });
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.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"