@mcowger/opencode-plexus 1.3.8 → 1.3.10

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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/dist/index.js +129 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -244,6 +244,47 @@ Both adapters also accept pi-style environment interpolation in configured strin
244
244
 
245
245
  ---
246
246
 
247
+ ## Model suppression
248
+
249
+ All plugin packages support suppressing models by name or pattern so undesired models do not appear in model selectors or cache restored lists.
250
+
251
+ ### Pattern matching syntax
252
+
253
+ - **Exact match** (case-insensitive): Matches model `id`, `name`, or short ID (suffix after `/` or `:`). Example: `"gpt-4o"`, `"Claude 3.5 Sonnet"`.
254
+ - **Glob wildcards**: Supports `*` (0+ characters) and `?` (1 character). Example: `"gpt-3.5*"`, `"*deprecated*"`, `"anthropic/*"`.
255
+ - **Regex patterns**: Prefix with `regex:`. Example: `"regex:^gpt-[34]"`.
256
+
257
+ ### Configuration methods
258
+
259
+ - **Environment variables**:
260
+ ```sh
261
+ export PLEXUS_SUPPRESS_MODELS="gpt-3.5*, *deprecated*, whisper"
262
+ ```
263
+ `PLEXUS_EXCLUDE_MODELS` is also accepted. Values can be comma-, semicolon-, or newline-separated.
264
+
265
+ - **pi / Oh My Pi config**: Add `suppressModels` (or `suppress`) to `config.json`:
266
+ ```json
267
+ {
268
+ "baseUrl": "https://plexus.example.com",
269
+ "suppressModels": ["gpt-3.5*", "*deprecated*"]
270
+ }
271
+ ```
272
+
273
+ - **OpenCode config**: Add `suppressModels` (or `suppress`) under `provider.plexus.options` in `opencode.json`:
274
+ ```json
275
+ {
276
+ "provider": {
277
+ "plexus": {
278
+ "options": {
279
+ "suppressModels": ["gpt-3.5*", "claude-2*"]
280
+ }
281
+ }
282
+ }
283
+ }
284
+ ```
285
+
286
+ ---
287
+
247
288
  ## Package layout
248
289
 
249
290
  ```
@@ -252,6 +293,7 @@ packages/
252
293
  src/
253
294
  types.ts # wire types (PlexusApiModel, PlexusModelDescriptor, etc.)
254
295
  convert.ts # model fetching, conversion, compat detection
296
+ suppress.ts # model pattern suppression / exclusion matching
255
297
  index.ts # barrel export
256
298
  plexus-pi/ # pi host adapter
257
299
  src/
package/dist/index.js CHANGED
@@ -9,12 +9,66 @@ var PLEXUS_BASE_URL_OPTION = "plexusBaseURL";
9
9
  var ENV_BASE_URL = "PLEXUS_BASE_URL";
10
10
  var ENV_API_URL = "PLEXUS_API_URL";
11
11
  var ENV_API_KEY = "PLEXUS_API_KEY";
12
+ var ENV_SUPPRESS_MODELS = "PLEXUS_SUPPRESS_MODELS";
13
+ var ENV_SUPPRESSED_MODELS = "PLEXUS_SUPPRESSED_MODELS";
14
+ var ENV_EXCLUDE_MODELS = "PLEXUS_EXCLUDE_MODELS";
15
+ var ENV_IGNORE_MODELS = "PLEXUS_IGNORE_MODELS";
16
+ var PLEXUS_SUPPRESS_MODELS_OPTION = "suppressModels";
12
17
  var MODELS_FETCH_TIMEOUT_MS = 1e4;
13
18
  var REFRESH_TTL_MS = 60000;
14
19
  var CONFIG_HOOK_REFRESH_BUDGET_MS = 3000;
15
20
  var PLACEHOLDER_MODEL_ID = "plexus-unconfigured";
16
21
  var PLEXUS_REFRESH_COMMAND = "plexus-refresh";
17
22
 
23
+ // ../plexus-models/src/suppress.ts
24
+ function parseSuppressionPatterns(raw) {
25
+ if (!raw)
26
+ return [];
27
+ const items = Array.isArray(raw) ? raw : raw.split(/[\n,;]+/);
28
+ return items.map((s) => s.trim()).filter((s) => s.length > 0);
29
+ }
30
+ function getEnvSuppressedModels() {
31
+ const env = typeof process !== "undefined" && process?.env ? process.env : {};
32
+ const raw = env.PLEXUS_SUPPRESS_MODELS ?? env.PLEXUS_EXCLUDE_MODELS;
33
+ return parseSuppressionPatterns(raw);
34
+ }
35
+ function isModelSuppressed(model, patterns) {
36
+ const envPatterns = getEnvSuppressedModels();
37
+ const explicitPatterns = parseSuppressionPatterns(patterns);
38
+ const allPatterns = [...envPatterns, ...explicitPatterns];
39
+ if (allPatterns.length === 0)
40
+ return false;
41
+ const id = model.id.toLowerCase();
42
+ const name = (model.name ?? "").toLowerCase();
43
+ const shortId = id.includes("/") ? id.split("/").pop() : id.includes(":") ? id.split(":").pop() : id;
44
+ for (const pattern of allPatterns) {
45
+ if (matchesPattern(id, name, shortId, pattern)) {
46
+ return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+ function matchesPattern(id, name, shortId, pattern) {
52
+ const p = pattern.toLowerCase();
53
+ if (p.startsWith("regex:")) {
54
+ try {
55
+ const re = new RegExp(pattern.slice(6), "i");
56
+ return re.test(id) || re.test(name) || re.test(shortId);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ if (p.includes("*") || p.includes("?")) {
62
+ const regexStr = "^" + p.replace(/([.+^${}()|[\]\\])/g, "\\$1").replace(/\*/g, ".*").replace(/\?/g, ".") + "$";
63
+ try {
64
+ const re = new RegExp(regexStr, "i");
65
+ return re.test(id) || re.test(name) || re.test(shortId);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ return id === p || name === p || shortId === p;
71
+ }
18
72
  // ../plexus-models/src/convert.ts
19
73
  var REASONING_PARAMS = new Set(["reasoning", "include_reasoning", "reasoning_effort"]);
20
74
  var NON_CHAT_PATTERN = /(?:^|[\W_])(?:embed(?:ding|dings)?|transcri(?:be[ds]?|ptions?)|whisper|speech[\W_]*to[\W_]*text|stt|text[\W_]*to[\W_]*speech|tts|image[\W_]*(?:gen(?:eration)?|\d+)|diffusion|dall[\W_]*e|stable[\W_]*diffusion|sdxl|dream)(?:$|[\W_])/i;
@@ -66,22 +120,28 @@ function isChatModel(model) {
66
120
  return !NON_CHAT_PATTERN.test(`${model.id} ${model.name ?? ""} ${apiHints}`);
67
121
  }
68
122
  var DEFAULT_MODELS_FETCH_TIMEOUT_MS = 1e4;
69
- async function fetchPlexusModels(apiKey, modelsUrl, timeoutMs = DEFAULT_MODELS_FETCH_TIMEOUT_MS) {
123
+ async function fetchPlexusModels(apiKey, modelsUrl, timeoutMs = DEFAULT_MODELS_FETCH_TIMEOUT_MS, etag) {
70
124
  const controller = new AbortController;
71
125
  const timer = setTimeout(() => controller.abort(), timeoutMs);
72
126
  try {
73
127
  const headers = { Accept: "application/json" };
74
128
  if (apiKey)
75
129
  headers.Authorization = `Bearer ${apiKey}`;
130
+ if (etag)
131
+ headers["If-None-Match"] = etag;
76
132
  const res = await fetch(modelsUrl, {
77
133
  headers,
78
134
  signal: controller.signal
79
135
  });
136
+ if (res.status === 304) {
137
+ return { models: [], notModified: true };
138
+ }
80
139
  if (!res.ok) {
81
140
  throw new Error(`Plexus models fetch failed: ${res.status} ${res.statusText}`);
82
141
  }
83
142
  const raw = await res.json();
84
- return { models: raw.data ?? [], raw };
143
+ const responseEtag = res.headers.get("etag") ?? undefined;
144
+ return { models: raw.data ?? [], raw, etag: responseEtag };
85
145
  } catch (err) {
86
146
  if (err instanceof Error && err.name === "AbortError") {
87
147
  throw new Error(`Plexus models fetch timed out after ${timeoutMs}ms`);
@@ -104,34 +164,41 @@ function fallbackDir() {
104
164
  function getDir() {
105
165
  return fallbackDir();
106
166
  }
107
- function filterCachedModels(models) {
108
- return Object.fromEntries(Object.entries(models).filter(([, model]) => isChatModel({
109
- id: model.id,
110
- name: model.name,
111
- architecture: {
112
- input_modalities: model.modalities.input,
113
- output_modalities: model.modalities.output
114
- }
115
- })));
167
+ function filterCachedModels(models, suppress) {
168
+ return Object.fromEntries(Object.entries(models).filter(([, model]) => {
169
+ if (isModelSuppressed({ id: model.id, name: model.name }, suppress))
170
+ return false;
171
+ return isChatModel({
172
+ id: model.id,
173
+ name: model.name,
174
+ architecture: {
175
+ input_modalities: model.modalities.input,
176
+ output_modalities: model.modalities.output
177
+ }
178
+ });
179
+ }));
116
180
  }
117
- async function readCachedModels(_client) {
181
+ async function readCachedModels(_client, suppress) {
118
182
  try {
119
183
  const dir = getDir();
120
184
  const content = await readFile(join(dir, CACHE_FILE), "utf8");
121
185
  const parsed = JSON.parse(content);
122
186
  if (parsed && typeof parsed.models === "object" && !Array.isArray(parsed.models)) {
123
- return filterCachedModels(parsed.models);
187
+ return {
188
+ models: filterCachedModels(parsed.models, suppress),
189
+ etag: typeof parsed.etag === "string" ? parsed.etag : undefined
190
+ };
124
191
  }
125
192
  return null;
126
193
  } catch {
127
194
  return null;
128
195
  }
129
196
  }
130
- async function writeCache(_client, models, raw) {
197
+ async function writeCache(_client, models, raw, etag) {
131
198
  try {
132
199
  const dir = getDir();
133
200
  await mkdir(dir, { recursive: true });
134
- const cache = { models, timestamp: Date.now() };
201
+ const cache = { models, timestamp: Date.now(), etag };
135
202
  await writeFile(join(dir, CACHE_FILE), JSON.stringify(cache, null, 2) + `
136
203
  `, "utf8");
137
204
  if (raw !== undefined) {
@@ -249,9 +316,24 @@ function resolveConfig(provider, authMetadata) {
249
316
  const optBaseURL = resolveStringOption(provider?.options?.[PLEXUS_BASE_URL_OPTION]);
250
317
  const legacyBaseURL = resolveStringOption(provider?.options?.baseURL);
251
318
  const optApiKey = resolveStringOption(provider?.options?.apiKey);
319
+ const optSuppress = provider?.options?.["suppressModels"] ?? provider?.options?.["suppress"] ?? provider?.options?.["suppress_models"];
320
+ const envSuppress = getEnvSuppressedModels();
321
+ const configSuppress = parseSuppressionPatterns(optSuppress);
322
+ const suppressModels = [...envSuppress, ...configSuppress];
252
323
  const baseURL = (envBaseURL ? rootURL(envBaseURL) : undefined) || (authBaseURL ? rootURL(authBaseURL) : undefined) || (optBaseURL ? rootURL(optBaseURL) : undefined) || (legacyBaseURL ? rootURL(legacyBaseURL) : undefined) || undefined;
253
324
  const apiKey = (envApiKey ? envApiKey.trim() : undefined) || optApiKey || undefined;
254
- return { baseURL: baseURL || undefined, apiKey: apiKey || undefined };
325
+ return {
326
+ baseURL: baseURL || undefined,
327
+ apiKey: apiKey || undefined,
328
+ ...suppressModels.length > 0 ? { suppressModels } : {}
329
+ };
330
+ }
331
+ function getSuppressedModels(provider) {
332
+ const envSuppressed = getEnvSuppressedModels();
333
+ const providerObj = provider ?? {};
334
+ const providerOptions = typeof providerObj["options"] === "object" && providerObj["options"] !== null ? providerObj["options"] : {};
335
+ const optSuppressed = parseSuppressionPatterns(providerOptions["suppressModels"] ?? providerOptions["suppress"] ?? providerObj["suppressModels"] ?? providerObj["suppress"]);
336
+ return [...envSuppressed, ...optSuppressed];
255
337
  }
256
338
 
257
339
  // src/log.ts
@@ -353,11 +435,13 @@ function buildOutputModalities(model) {
353
435
  }
354
436
  return ["text"];
355
437
  }
356
- function buildModels(models, baseURL) {
438
+ function buildModels(models, baseURL, suppress) {
357
439
  const result = {};
358
440
  for (const m of models) {
359
441
  if (!isChatModel(m))
360
442
  continue;
443
+ if (isModelSuppressed(m, suppress))
444
+ continue;
361
445
  const outputModalities = buildOutputModalities(m);
362
446
  if (outputModalities === null)
363
447
  continue;
@@ -497,7 +581,7 @@ function raceWithTimeout(promise, timeoutMs) {
497
581
  });
498
582
  });
499
583
  }
500
- function refreshModels(client, baseURL, log, apiKey, force = false) {
584
+ function refreshModels(client, baseURL, log, apiKey, force = false, suppress) {
501
585
  if (!force && lastRefresh && Date.now() - lastRefresh.at < REFRESH_TTL_MS) {
502
586
  log.info(`Using in-memory plexus model cache (${Object.keys(lastRefresh.models).length} models)`);
503
587
  return Promise.resolve(lastRefresh.models);
@@ -506,15 +590,21 @@ function refreshModels(client, baseURL, log, apiKey, force = false) {
506
590
  return inFlightRefresh;
507
591
  const run = async () => {
508
592
  const url = modelsUrl(baseURL);
509
- const { models: apiModels, raw } = await fetchPlexusModels(apiKey ?? "", url);
510
- const built = buildModels(apiModels, apiBase(baseURL));
593
+ const cached = await readCachedModels(client, suppress);
594
+ const { models: apiModels, raw, etag, notModified } = await fetchPlexusModels(apiKey ?? "", url, undefined, cached?.etag);
595
+ if (notModified && cached?.models) {
596
+ log.info(`Plexus models not modified (etag: ${cached.etag})`);
597
+ lastRefresh = { at: Date.now(), models: cached.models };
598
+ return cached.models;
599
+ }
600
+ const built = buildModels(apiModels, apiBase(baseURL), suppress);
511
601
  for (const [id, model] of Object.entries(built)) {
512
602
  const providerNpm = model.provider?.npm ?? OPENAI_COMPATIBLE_NPM;
513
603
  const providerApi = model.provider?.api ?? "(missing)";
514
604
  log.info(`Model mapping ${id}: npm=${providerNpm} api=${providerApi}`);
515
605
  }
516
606
  lastRefresh = { at: Date.now(), models: built };
517
- writeCache(client, built, raw).catch(() => {});
607
+ writeCache(client, built, raw, etag).catch(() => {});
518
608
  return built;
519
609
  };
520
610
  inFlightRefresh = run().finally(() => {
@@ -531,15 +621,17 @@ var PlexusProviderPlugin = async (ctx) => {
531
621
  const existing = cfg.provider[PLEXUS_PROVIDER_ID] ?? {};
532
622
  const existingOptions = typeof existing["options"] === "object" && existing["options"] !== null ? existing["options"] : {};
533
623
  const existingModels = typeof existing["models"] === "object" && existing["models"] !== null ? existing["models"] : null;
624
+ const suppress = getSuppressedModels(existing);
534
625
  const { baseURL, apiKey } = resolveConfig(existing);
535
626
  log.info(`Resolved plexus config: baseURL=${baseURL ?? "(missing)"} apiKey=${apiKey ? "present" : "missing"}`);
536
627
  if (typeof existingOptions["baseURL"] === "string") {
537
628
  log.warn(`Ignoring legacy provider.options.baseURL=${String(existingOptions["baseURL"])}`);
538
629
  }
539
- const cachedAsync = await readCachedModels(client);
630
+ const cachedAsync = await readCachedModels(client, suppress);
540
631
  if (cachedAsync) {
541
- log.info(`Loaded plexus cache with ${Object.keys(cachedAsync).length} models`);
632
+ log.info(`Loaded plexus cache with ${Object.keys(cachedAsync.models).length} models`);
542
633
  }
634
+ const effectiveExistingModels = existingModels ? filterCachedModels(existingModels, suppress) : null;
543
635
  const merged = {
544
636
  ...existing,
545
637
  name: existing["name"] ?? PLEXUS_PROVIDER_NAME,
@@ -549,7 +641,7 @@ var PlexusProviderPlugin = async (ctx) => {
549
641
  ...baseURL ? { [PLEXUS_BASE_URL_OPTION]: baseURL } : {},
550
642
  ...apiKey ? { apiKey } : {}
551
643
  },
552
- models: existingModels ?? (cachedAsync ? toConfigModels(cachedAsync) : null) ?? {
644
+ models: (effectiveExistingModels && Object.keys(effectiveExistingModels).length > 0 ? effectiveExistingModels : null) ?? (cachedAsync ? toConfigModels(cachedAsync.models) : null) ?? {
553
645
  [PLACEHOLDER_MODEL_ID]: {
554
646
  id: PLACEHOLDER_MODEL_ID,
555
647
  name: "Plexus (run /connect to configure)",
@@ -584,21 +676,22 @@ var PlexusProviderPlugin = async (ctx) => {
584
676
  provider: {
585
677
  id: PLEXUS_PROVIDER_ID,
586
678
  models: async (provider, hookCtx) => {
679
+ const suppress = getSuppressedModels(provider);
587
680
  const authKey = hookCtx.auth?.type === "api" ? hookCtx.auth.key : undefined;
588
681
  const { baseURL, apiKey } = resolveConfig(provider, authMetadata(hookCtx.auth));
589
682
  const key = authKey ?? apiKey;
590
683
  if (!baseURL) {
591
684
  log.info("Provider hook skipped live refresh; baseURL missing");
592
- const cached2 = await readCachedModels(client);
593
- return cached2 ? toRuntimeModels(cached2, provider) : {};
685
+ const cached2 = await readCachedModels(client, suppress);
686
+ return cached2 ? toRuntimeModels(cached2.models, provider) : {};
594
687
  }
595
- const refreshPromise = refreshModels(client, baseURL, log, key);
688
+ const refreshPromise = refreshModels(client, baseURL, log, key, false, suppress);
596
689
  const race = await raceWithTimeout(refreshPromise, CONFIG_HOOK_REFRESH_BUDGET_MS);
597
690
  if (race.status === "resolved") {
598
691
  log.info(`Provider hook loaded ${Object.keys(race.value).length} plexus models from ${baseURL}`);
599
692
  return toRuntimeModels(race.value, provider);
600
693
  }
601
- const cached = await readCachedModels(client);
694
+ const cached = await readCachedModels(client, suppress);
602
695
  if (race.status === "rejected") {
603
696
  log.warn(`Provider hook live refresh failed, using cache: ${String(race.error)}`);
604
697
  } else {
@@ -607,7 +700,7 @@ var PlexusProviderPlugin = async (ctx) => {
607
700
  log.warn(`Background plexus model refresh failed: ${String(e)}`);
608
701
  });
609
702
  }
610
- return cached ? toRuntimeModels(cached, provider) : {};
703
+ return cached ? toRuntimeModels(cached.models, provider) : {};
611
704
  }
612
705
  },
613
706
  "command.execute.before": async (input) => {
@@ -615,6 +708,7 @@ var PlexusProviderPlugin = async (ctx) => {
615
708
  return;
616
709
  const configResponse = await client.config.get();
617
710
  const provider = configResponse.data?.provider?.[PLEXUS_PROVIDER_ID];
711
+ const suppress = getSuppressedModels(provider);
618
712
  const storedAuth = await readStoredAuth();
619
713
  const { baseURL, apiKey } = resolveConfig(provider, storedAuth?.metadata);
620
714
  const key = storedAuth?.key ?? apiKey;
@@ -623,7 +717,7 @@ var PlexusProviderPlugin = async (ctx) => {
623
717
  }
624
718
  let models;
625
719
  try {
626
- models = await refreshModels(client, baseURL, log, key, true);
720
+ models = await refreshModels(client, baseURL, log, key, true, suppress);
627
721
  } catch (e) {
628
722
  throw new Error(`Plexus refresh failed: ${String(e)}. Existing cache left untouched.`);
629
723
  }
@@ -692,6 +786,7 @@ export {
692
786
  buildModels,
693
787
  REFRESH_TTL_MS,
694
788
  PlexusProviderPlugin,
789
+ PLEXUS_SUPPRESS_MODELS_OPTION,
695
790
  PLEXUS_REFRESH_COMMAND,
696
791
  PLEXUS_PROVIDER_NAME,
697
792
  PLEXUS_PROVIDER_ID,
@@ -701,6 +796,10 @@ export {
701
796
  PLACEHOLDER_MODEL_ID,
702
797
  OPENAI_COMPATIBLE_NPM,
703
798
  MODELS_FETCH_TIMEOUT_MS,
799
+ ENV_SUPPRESS_MODELS,
800
+ ENV_SUPPRESSED_MODELS,
801
+ ENV_IGNORE_MODELS,
802
+ ENV_EXCLUDE_MODELS,
704
803
  ENV_BASE_URL,
705
804
  ENV_API_URL,
706
805
  ENV_API_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcowger/opencode-plexus",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
4
4
  "description": "OpenCode plugin: Plexus provider with dynamic model discovery",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",