@mcowger/opencode-plexus 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/index.js +441 -0
  4. package/package.json +36 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Matt Cowger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @mcowger/opencode-plexus
2
+
3
+ An [OpenCode](https://opencode.ai) plugin that exposes a self-hosted [Plexus](https://github.com/mcowger/plexus) instance as a first-class `plexus` provider with **dynamic model discovery**.
4
+
5
+ Models are fetched live from your Plexus instance's `/v1/models` endpoint on every startup, and cached on-disk so OpenCode starts cleanly even when the network is unavailable.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ opencode plugin --global @mcowger/opencode-plexus
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Setup
18
+
19
+ ### Option A — Interactive auth (recommended)
20
+
21
+ ```bash
22
+ opencode auth login --provider plexus
23
+ ```
24
+
25
+ You will be prompted for:
26
+ - **Plexus base URL** — e.g. `https://plexus.example.com`
27
+ - **API key** — the key used to authenticate chat-completion requests
28
+
29
+ OpenCode will probe the URL, then persist both values in its global config so you only need to do this once.
30
+
31
+ ### Option B — Environment variables
32
+
33
+ ```bash
34
+ export PLEXUS_BASE_URL=https://plexus.example.com
35
+ export PLEXUS_API_KEY=sk-...
36
+ ```
37
+
38
+ Environment variables take precedence over the stored config.
39
+
40
+ ### Option C — Manual `opencode.json`
41
+
42
+ ```jsonc
43
+ {
44
+ "provider": {
45
+ "plexus": {
46
+ "options": {
47
+ "baseURL": "https://plexus.example.com",
48
+ "apiKey": "sk-..."
49
+ }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ ---
56
+
57
+ ## How it works
58
+
59
+ 1. On startup the plugin's `config` hook fires.
60
+ 2. It reads `PLEXUS_BASE_URL` / `PLEXUS_API_KEY` (or the stored options) and calls `/v1/models` on your Plexus instance.
61
+ 3. The response is transformed into OpenCode's model schema and registered under the `plexus` provider.
62
+ 4. The transformed list is cached in OpenCode's state directory (`~/.local/share/opencode/plugins/plexus/`).
63
+ 5. On the next startup the cache is loaded synchronously before the live refresh completes, so the model picker is always populated.
64
+
65
+ > **Note:** `/v1/models` does not require an API key. The API key is only required for chat-completion requests. You can run the plugin without an API key if you only want to browse models.
66
+
67
+ ---
68
+
69
+ ## Model discovery details
70
+
71
+ | Plexus field | OpenCode model field |
72
+ |---|---|
73
+ | `id` | `id` (also dict key) |
74
+ | `name` | `name` |
75
+ | `context_length` / `top_provider.context_length` | `limit.context` |
76
+ | `top_provider.max_completion_tokens` | `limit.output` (fallback: `ceil(context × 0.2)`) |
77
+ | `pricing.prompt` / `.completion` | `cost.input` / `.output` (per-million tokens) |
78
+ | `pricing.input_cache_read` / `.input_cache_write` | `cost.cache_read` / `.cache_write` |
79
+ | `architecture.input_modalities` | `modalities.input` (`file` → `pdf`) |
80
+ | `architecture.output_modalities` | `modalities.output` |
81
+ | `output_modalities` present but does not include `text` | **model skipped** (filters image-generation, embedding, TTS, and other non-chat output types) |
82
+ | no `architecture` field and id matches `embedding`, `tts`, `whisper`, `image-*`, `dream`, etc. | **model skipped** (bare-stub non-chat models with no metadata) |
83
+ | `supported_parameters` includes `tools` | `tool_call: true` |
84
+ | `supported_parameters` includes `reasoning` / `include_reasoning` / `reasoning_effort` | `reasoning: true` |
85
+ | `supported_parameters` includes `temperature` | `temperature: true` |
86
+ | any non-text input modality | `attachment: true` |
87
+
88
+ ---
89
+
90
+ ## Multi-API escape hatch
91
+
92
+ By default this plugin uses `@ai-sdk/openai-compatible` (the OpenAI-compatible route) for all models. For Plexus-proxied models that use a different API wire format (e.g. Anthropic messages), add a **sibling provider** manually in your `opencode.json`:
93
+
94
+ ```jsonc
95
+ {
96
+ "provider": {
97
+ "plexus": {
98
+ // managed by this plugin — do not edit models here
99
+ },
100
+ "plexus-anthropic": {
101
+ "npm": "@ai-sdk/anthropic",
102
+ "options": {
103
+ "baseURL": "https://plexus.example.com"
104
+ },
105
+ "models": {
106
+ "claude-sonnet-4-6": {
107
+ "name": "Claude Sonnet 4.6 (via Plexus, Anthropic wire)",
108
+ "attachment": true,
109
+ "reasoning": true,
110
+ "tool_call": true,
111
+ "cost": { "input": 3.0, "output": 15.0 },
112
+ "limit": { "context": 1000000, "output": 128000 }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ The `plexus` provider itself only manages the OpenAI-compatible route.
121
+
122
+ ---
123
+
124
+ ## Troubleshooting
125
+
126
+ **Models don't appear after setup**
127
+ - Check that `PLEXUS_BASE_URL` is set or that you completed `opencode auth login --provider plexus`.
128
+ - Verify reachability: `curl https://plexus.example.com/v1/models`.
129
+
130
+ **Cache location**
131
+ The model cache lives under OpenCode's own state directory:
132
+
133
+ ```
134
+ ~/.local/share/opencode/plugins/plexus/models-cache.json
135
+ ~/.local/share/opencode/plugins/plexus/models-raw.json
136
+ ```
137
+
138
+ Deleting OpenCode's state directory (the documented reset path) also clears this cache.
139
+
140
+ **API key vs base URL**
141
+ - `PLEXUS_API_KEY` (and the stored key) is used **only** for chat-completion requests.
142
+ - `/v1/models` is fetched without authentication, so the model picker works even if you haven't set an API key yet.
package/dist/index.js ADDED
@@ -0,0 +1,441 @@
1
+ // @bun
2
+ // src/constants.ts
3
+ var PLEXUS_PROVIDER_ID = "plexus";
4
+ var PLEXUS_PROVIDER_NAME = "Plexus";
5
+ var PLEXUS_PLUGIN_ID = "@mcowger/opencode-plexus";
6
+ var PLEXUS_LOG_SERVICE = "opencode-plexus";
7
+ var OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible";
8
+ var ENV_BASE_URL = "PLEXUS_BASE_URL";
9
+ var ENV_API_KEY = "PLEXUS_API_KEY";
10
+ var MODELS_FETCH_TIMEOUT_MS = 1e4;
11
+ var REFRESH_TTL_MS = 60000;
12
+ var DEFAULT_CONTEXT = 8192;
13
+ var PLACEHOLDER_MODEL_ID = "plexus-unconfigured";
14
+
15
+ // src/cache.ts
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
17
+ import { mkdir, readFile, writeFile } from "fs/promises";
18
+ import { homedir } from "os";
19
+ import { join } from "path";
20
+ var PLUGIN_SUBDIR = join("plugins", "plexus");
21
+ var CACHE_FILE = "models-cache.json";
22
+ var RAW_FILE = "models-raw.json";
23
+ var resolvedDir = null;
24
+ function fallbackDir() {
25
+ return join(homedir(), ".local", "share", "opencode", PLUGIN_SUBDIR);
26
+ }
27
+ async function getDir(client) {
28
+ if (resolvedDir)
29
+ return resolvedDir;
30
+ try {
31
+ const res = await client.path.get();
32
+ const data = res?.data;
33
+ const state = typeof data?.state === "string" && data.state ? data.state : undefined;
34
+ if (state) {
35
+ resolvedDir = join(state, PLUGIN_SUBDIR);
36
+ return resolvedDir;
37
+ }
38
+ } catch {}
39
+ resolvedDir = fallbackDir();
40
+ return resolvedDir;
41
+ }
42
+ function syncCachePath() {
43
+ return join(fallbackDir(), CACHE_FILE);
44
+ }
45
+ function readCachedModelsSync() {
46
+ try {
47
+ const path = syncCachePath();
48
+ if (!existsSync(path))
49
+ return null;
50
+ const raw = readFileSync(path, "utf8");
51
+ const parsed = JSON.parse(raw);
52
+ if (parsed && typeof parsed.models === "object" && !Array.isArray(parsed.models)) {
53
+ return parsed.models;
54
+ }
55
+ return null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ async function readCachedModels(client) {
61
+ try {
62
+ const dir = await getDir(client);
63
+ const content = await readFile(join(dir, CACHE_FILE), "utf8");
64
+ const parsed = JSON.parse(content);
65
+ if (parsed && typeof parsed.models === "object" && !Array.isArray(parsed.models)) {
66
+ return parsed.models;
67
+ }
68
+ return null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ async function writeCache(client, models, raw) {
74
+ try {
75
+ const dir = await getDir(client);
76
+ await mkdir(dir, { recursive: true });
77
+ const cache = { models, timestamp: Date.now() };
78
+ await writeFile(join(dir, CACHE_FILE), JSON.stringify(cache, null, 2) + `
79
+ `, "utf8");
80
+ if (raw !== undefined) {
81
+ await writeFile(join(dir, RAW_FILE), JSON.stringify(raw, null, 2) + `
82
+ `, "utf8");
83
+ }
84
+ try {
85
+ const syncDir = fallbackDir();
86
+ mkdirSync(syncDir, { recursive: true });
87
+ writeFileSync(join(syncDir, CACHE_FILE), JSON.stringify(cache, null, 2) + `
88
+ `, "utf8");
89
+ } catch {}
90
+ } catch {}
91
+ }
92
+
93
+ // src/config-store.ts
94
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
95
+
96
+ // src/url.ts
97
+ function trimURL(s) {
98
+ return s.trim().replace(/\/+$/, "");
99
+ }
100
+ function apiBase(baseURL) {
101
+ const next = trimURL(baseURL);
102
+ if (!next)
103
+ return "";
104
+ return next.endsWith("/v1") ? next : `${next}/v1`;
105
+ }
106
+ function modelsUrl(baseURL) {
107
+ const base = apiBase(baseURL);
108
+ return base ? `${base}/models` : "";
109
+ }
110
+
111
+ // src/config-store.ts
112
+ function getV1ClientConfig(input) {
113
+ return input._client?.getConfig?.() ?? {};
114
+ }
115
+ function createV2Client(serverUrl, input) {
116
+ const v1Config = getV1ClientConfig(input);
117
+ return createOpencodeClient({
118
+ baseUrl: serverUrl.toString(),
119
+ fetch: v1Config.fetch,
120
+ headers: v1Config.headers,
121
+ throwOnError: true
122
+ });
123
+ }
124
+ function resolveConfig(provider) {
125
+ const envBaseURL = process.env[ENV_BASE_URL];
126
+ const envApiKey = process.env[ENV_API_KEY];
127
+ const optBaseURL = typeof provider?.options?.baseURL === "string" ? trimURL(provider.options.baseURL) : undefined;
128
+ const optApiKey = typeof provider?.options?.apiKey === "string" ? provider.options.apiKey.trim() : undefined;
129
+ const baseURL = (envBaseURL ? trimURL(envBaseURL) : undefined) || optBaseURL || undefined;
130
+ const apiKey = (envApiKey ? envApiKey.trim() : undefined) || optApiKey || undefined;
131
+ return { baseURL: baseURL || undefined, apiKey: apiKey || undefined };
132
+ }
133
+ async function persistToGlobalConfig(serverUrl, client, baseURL, apiKey) {
134
+ const v2 = createV2Client(serverUrl, client);
135
+ await v2.global.config.update({
136
+ config: {
137
+ provider: {
138
+ [PLEXUS_PROVIDER_ID]: {
139
+ options: { baseURL, apiKey }
140
+ }
141
+ }
142
+ }
143
+ });
144
+ }
145
+
146
+ // src/log.ts
147
+ function createLogger(client) {
148
+ function log(level, message) {
149
+ client.app.log({ body: { service: PLEXUS_LOG_SERVICE, level, message } }).catch(() => {});
150
+ }
151
+ return {
152
+ info: (message) => log("info", message),
153
+ warn: (message) => log("warn", message),
154
+ error: (message) => log("error", message)
155
+ };
156
+ }
157
+
158
+ // src/models.ts
159
+ var REASONING_PARAMS = new Set(["reasoning", "include_reasoning", "reasoning_effort"]);
160
+ function parsePrice(value) {
161
+ if (!value)
162
+ return 0;
163
+ const n = parseFloat(value);
164
+ return Number.isNaN(n) ? 0 : n;
165
+ }
166
+ function mapModality(m) {
167
+ switch (m) {
168
+ case "text":
169
+ return "text";
170
+ case "image":
171
+ return "image";
172
+ case "audio":
173
+ return "audio";
174
+ case "video":
175
+ return "video";
176
+ case "file":
177
+ case "pdf":
178
+ return "pdf";
179
+ default:
180
+ return null;
181
+ }
182
+ }
183
+ function buildInputModalities(model) {
184
+ const raw = model.architecture?.input_modalities ?? [];
185
+ const mapped = raw.map(mapModality).filter((m) => m !== null);
186
+ return mapped.length > 0 ? [...new Set(mapped)] : ["text"];
187
+ }
188
+ var NON_CHAT_ID_PATTERN = /embedding|embed|tts|whisper|image-[0-9]|image\b.*gen|diffusion|dall-e|stable-diff|sdxl|dream/i;
189
+ function buildOutputModalities(model) {
190
+ const raw = model.architecture?.output_modalities;
191
+ if (raw !== undefined) {
192
+ if (!raw.includes("text"))
193
+ return null;
194
+ const mapped = raw.map(mapModality).filter((m) => m !== null);
195
+ return mapped.length > 0 ? [...new Set(mapped)] : ["text"];
196
+ }
197
+ if (NON_CHAT_ID_PATTERN.test(model.id))
198
+ return null;
199
+ return ["text"];
200
+ }
201
+ function buildModels(models) {
202
+ const result = {};
203
+ for (const m of models) {
204
+ if (!m.id)
205
+ continue;
206
+ const outputModalities = buildOutputModalities(m);
207
+ if (outputModalities === null)
208
+ continue;
209
+ const inputModalities = buildInputModalities(m);
210
+ const params = m.supported_parameters ?? [];
211
+ const contextLength = (typeof m.context_length === "number" && m.context_length > 0 ? m.context_length : undefined) ?? (typeof m.top_provider?.context_length === "number" && m.top_provider.context_length > 0 ? m.top_provider.context_length : undefined) ?? DEFAULT_CONTEXT;
212
+ const maxOutput = (typeof m.top_provider?.max_completion_tokens === "number" && m.top_provider.max_completion_tokens > 0 ? m.top_provider.max_completion_tokens : undefined) ?? Math.ceil(contextLength * 0.2);
213
+ const promptPrice = parsePrice(m.pricing?.prompt);
214
+ const completionPrice = parsePrice(m.pricing?.completion);
215
+ const cacheReadPrice = parsePrice(m.pricing?.input_cache_read);
216
+ const cacheWritePrice = parsePrice(m.pricing?.input_cache_write);
217
+ const hasCachePricing = cacheReadPrice > 0 || cacheWritePrice > 0;
218
+ const hasNonTextInput = inputModalities.some((mod) => mod !== "text");
219
+ const entry = {
220
+ id: m.id,
221
+ name: m.name ?? m.id,
222
+ limit: {
223
+ context: contextLength,
224
+ output: maxOutput
225
+ },
226
+ modalities: {
227
+ input: inputModalities,
228
+ output: outputModalities
229
+ },
230
+ ...promptPrice > 0 || completionPrice > 0 ? {
231
+ cost: {
232
+ input: promptPrice,
233
+ output: completionPrice,
234
+ ...hasCachePricing ? { cache_read: cacheReadPrice, cache_write: cacheWritePrice } : {}
235
+ }
236
+ } : {},
237
+ ...params.includes("tools") ? { tool_call: true } : {},
238
+ ...params.some((p) => REASONING_PARAMS.has(p)) ? { reasoning: true } : {},
239
+ ...params.includes("temperature") ? { temperature: true } : {},
240
+ ...hasNonTextInput ? { attachment: true } : {}
241
+ };
242
+ result[m.id] = entry;
243
+ }
244
+ return result;
245
+ }
246
+
247
+ // src/plexus-client.ts
248
+ import { z } from "zod";
249
+ var PlexusModelArchitectureSchema = z.object({
250
+ modality: z.string().optional(),
251
+ input_modalities: z.array(z.string()).optional(),
252
+ output_modalities: z.array(z.string()).optional(),
253
+ tokenizer: z.string().optional(),
254
+ instruct_type: z.string().nullable().optional()
255
+ }).passthrough();
256
+ var PlexusModelPricingSchema = z.object({
257
+ prompt: z.string().optional(),
258
+ completion: z.string().optional(),
259
+ input_cache_read: z.string().optional(),
260
+ input_cache_write: z.string().optional()
261
+ }).passthrough();
262
+ var PlexusTopProviderSchema = z.object({
263
+ context_length: z.number().nullable().optional(),
264
+ max_completion_tokens: z.number().nullable().optional(),
265
+ is_moderated: z.boolean().optional()
266
+ }).passthrough();
267
+ var PlexusApiModelSchema = z.object({
268
+ id: z.string(),
269
+ object: z.string().optional(),
270
+ created: z.number().optional(),
271
+ owned_by: z.string().optional(),
272
+ preferred_api: z.union([z.string(), z.array(z.string())]).optional(),
273
+ name: z.string().optional(),
274
+ description: z.string().optional(),
275
+ context_length: z.number().nullable().optional(),
276
+ architecture: PlexusModelArchitectureSchema.optional(),
277
+ pricing: PlexusModelPricingSchema.optional(),
278
+ supported_parameters: z.array(z.string()).optional(),
279
+ top_provider: PlexusTopProviderSchema.optional(),
280
+ pi_provider: z.string().optional(),
281
+ pi_model: z.string().optional()
282
+ }).passthrough();
283
+ var PlexusApiResponseSchema = z.object({
284
+ object: z.string(),
285
+ data: z.array(PlexusApiModelSchema)
286
+ });
287
+ async function fetchPlexusModels(baseURL, apiKey) {
288
+ const url = modelsUrl(baseURL);
289
+ if (!url)
290
+ throw new Error("Plexus: cannot build models URL from an empty baseURL");
291
+ const headers = {
292
+ Accept: "application/json"
293
+ };
294
+ if (apiKey) {
295
+ headers["Authorization"] = `Bearer ${apiKey}`;
296
+ }
297
+ const response = await fetch(url, {
298
+ headers,
299
+ signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS)
300
+ });
301
+ if (!response.ok) {
302
+ throw new Error(`Plexus models fetch failed: HTTP ${response.status} ${response.statusText} (${url})`);
303
+ }
304
+ const json = await response.json();
305
+ const parsed = PlexusApiResponseSchema.safeParse(json);
306
+ if (!parsed.success) {
307
+ throw new Error(`Plexus models response did not match expected schema: ${parsed.error.message}`);
308
+ }
309
+ const raw = parsed.data;
310
+ return { models: raw.data ?? [], raw };
311
+ }
312
+
313
+ // src/plugin.ts
314
+ var lastRefresh = null;
315
+ async function refreshModels(client, baseURL, apiKey) {
316
+ if (lastRefresh && Date.now() - lastRefresh.at < REFRESH_TTL_MS) {
317
+ return lastRefresh.models;
318
+ }
319
+ const { models: apiModels, raw } = await fetchPlexusModels(baseURL, apiKey);
320
+ const built = buildModels(apiModels);
321
+ lastRefresh = { at: Date.now(), models: built };
322
+ writeCache(client, built, raw).catch(() => {});
323
+ return built;
324
+ }
325
+ var PlexusProviderPlugin = async (ctx) => {
326
+ const { client } = ctx;
327
+ const log = createLogger(client);
328
+ return {
329
+ config: async (cfg) => {
330
+ cfg.provider ??= {};
331
+ const existing = cfg.provider[PLEXUS_PROVIDER_ID] ?? {};
332
+ const existingOptions = typeof existing["options"] === "object" && existing["options"] !== null ? existing["options"] : {};
333
+ const existingModels = typeof existing["models"] === "object" && existing["models"] !== null ? existing["models"] : null;
334
+ const { baseURL, apiKey } = resolveConfig(existing);
335
+ const cachedSync = readCachedModelsSync();
336
+ const merged = {
337
+ ...existing,
338
+ name: existing["name"] ?? PLEXUS_PROVIDER_NAME,
339
+ npm: existing["npm"] ?? OPENAI_COMPATIBLE_NPM,
340
+ options: {
341
+ ...existingOptions,
342
+ ...baseURL ? { baseURL: apiBase(baseURL) } : {},
343
+ ...apiKey ? { apiKey } : {}
344
+ },
345
+ models: existingModels ?? cachedSync ?? {
346
+ [PLACEHOLDER_MODEL_ID]: {
347
+ id: PLACEHOLDER_MODEL_ID,
348
+ name: "Plexus (run /connect to configure)",
349
+ limit: { context: 1024, output: 1024 },
350
+ modalities: { input: ["text"], output: ["text"] }
351
+ }
352
+ }
353
+ };
354
+ if (baseURL) {
355
+ try {
356
+ const built = await refreshModels(client, baseURL, apiKey);
357
+ merged["models"] = { ...built, ...existingModels ?? {} };
358
+ log.info(`Loaded ${Object.keys(built).length} plexus models from ${baseURL}`);
359
+ } catch (e) {
360
+ log.warn(`Live model refresh failed, using cache: ${String(e)}`);
361
+ const cached = await readCachedModels(client);
362
+ if (cached) {
363
+ merged["models"] = { ...cached, ...existingModels ?? {} };
364
+ }
365
+ }
366
+ } else {
367
+ log.info("Plexus baseURL not configured; skipping live refresh");
368
+ }
369
+ cfg.provider[PLEXUS_PROVIDER_ID] = merged;
370
+ },
371
+ auth: {
372
+ provider: PLEXUS_PROVIDER_ID,
373
+ async loader(getAuth, providerInfo) {
374
+ const auth = await getAuth();
375
+ const { baseURL, apiKey } = resolveConfig(providerInfo);
376
+ const key = (auth?.type === "api" ? auth.key : undefined) ?? apiKey;
377
+ return {
378
+ ...baseURL ? { baseURL: apiBase(baseURL) } : {},
379
+ ...key ? { apiKey: key } : {}
380
+ };
381
+ },
382
+ methods: [
383
+ {
384
+ type: "api",
385
+ label: "Plexus API key",
386
+ prompts: [
387
+ {
388
+ type: "text",
389
+ key: "baseURL",
390
+ message: "Plexus base URL",
391
+ placeholder: "https://plexus.example.com",
392
+ validate: (v) => trimURL(v) ? undefined : "URL is required"
393
+ }
394
+ ],
395
+ async authorize(inputs = {}) {
396
+ const baseURL = trimURL(inputs["baseURL"] ?? "");
397
+ const apiKey = (inputs["apiKey"] ?? "").trim();
398
+ if (!baseURL || !apiKey)
399
+ return { type: "failed" };
400
+ try {
401
+ await fetchPlexusModels(baseURL);
402
+ } catch (e) {
403
+ log.error(`Plexus URL probe failed at ${baseURL}: ${String(e)}`);
404
+ return { type: "failed" };
405
+ }
406
+ try {
407
+ await persistToGlobalConfig(ctx.serverUrl, client, baseURL, apiKey);
408
+ } catch (e) {
409
+ log.error(`Failed to persist Plexus config: ${String(e)}`);
410
+ }
411
+ lastRefresh = null;
412
+ return { type: "success", provider: PLEXUS_PROVIDER_ID, key: apiKey };
413
+ }
414
+ }
415
+ ]
416
+ }
417
+ };
418
+ };
419
+
420
+ // src/index.ts
421
+ var plugin2 = {
422
+ id: PLEXUS_PLUGIN_ID,
423
+ server: PlexusProviderPlugin
424
+ };
425
+ var src_default = plugin2;
426
+ export {
427
+ src_default as default,
428
+ buildModels,
429
+ REFRESH_TTL_MS,
430
+ PlexusProviderPlugin,
431
+ PLEXUS_PROVIDER_NAME,
432
+ PLEXUS_PROVIDER_ID,
433
+ PLEXUS_PLUGIN_ID,
434
+ PLEXUS_LOG_SERVICE,
435
+ PLACEHOLDER_MODEL_ID,
436
+ OPENAI_COMPATIBLE_NPM,
437
+ MODELS_FETCH_TIMEOUT_MS,
438
+ ENV_BASE_URL,
439
+ ENV_API_KEY,
440
+ DEFAULT_CONTEXT
441
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@mcowger/opencode-plexus",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin: Plexus provider with dynamic model discovery",
5
+ "type": "module",
6
+ "module": "./dist/index.js",
7
+ "main": "./dist/index.js",
8
+ "exports": {
9
+ ".": "./dist/index.js",
10
+ "./server": "./dist/index.js"
11
+ },
12
+ "files": ["dist", "README.md", "LICENSE"],
13
+ "license": "MIT",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "bun build src/index.ts --outdir=dist --target=bun --format=esm --packages=external",
19
+ "watch": "bun build src/index.ts --outdir=dist --target=bun --format=esm --packages=external --watch",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "bun test ./tests",
22
+ "prepublishOnly": "bun run build"
23
+ },
24
+ "dependencies": {
25
+ "@opencode-ai/plugin": "^1.15.7",
26
+ "@opencode-ai/sdk": "^1.15.7",
27
+ "zod": "^4.4.3"
28
+ },
29
+ "peerDependencies": {
30
+ "typescript": "^6"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "^1.3.14",
34
+ "typescript": "^6.0.3"
35
+ }
36
+ }