@mcowger/opencode-plexus 0.8.3 → 1.0.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 (3) hide show
  1. package/README.md +43 -8
  2. package/dist/index.js +220 -128
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -88,10 +88,10 @@ Then reference the built artifact in `opencode.json`:
88
88
 
89
89
  ### pi
90
90
 
91
- Run inside pi:
91
+ Run inside pi using the native login flow:
92
92
 
93
93
  ```
94
- /plexus login
94
+ /login plexus
95
95
  ```
96
96
 
97
97
  You will be prompted for:
@@ -106,6 +106,8 @@ To force a model refresh:
106
106
  /plexus refresh
107
107
  ```
108
108
 
109
+ Use `/login plexus` for setup and `/logout plexus` to remove stored credentials.
110
+
109
111
  ### OpenCode
110
112
 
111
113
  Run inside OpenCode:
@@ -114,14 +116,19 @@ Run inside OpenCode:
114
116
  /connect
115
117
  ```
116
118
 
117
- Select **Plexus** and enter your base URL and API key. Models are loaded immediately and cached for fast startup on subsequent sessions.
119
+ Select **Plexus** and enter your base URL and API key. Models are discovered through OpenCode's provider hook and cached for fast startup on subsequent sessions.
118
120
 
119
- For OpenCode, enter the Plexus API base URL including the trailing `/v1`, for example:
121
+ You may enter either the Plexus root URL or the `/v1` API base URL:
120
122
 
121
123
  ```text
124
+ https://plexus.example.com
122
125
  https://plexus.example.com/v1
123
126
  ```
124
127
 
128
+ The plugins normalize either form to the Plexus root URL for storage and derive `/v1` paths when calling the API.
129
+
130
+ OpenCode stores the API key in its native auth store and stores the Plexus base URL as auth metadata on that connection. Existing `provider.plexus.options.plexusBaseURL` config is still honored as a fallback.
131
+
125
132
  The OpenCode plugin respects each model's `preferred_api` value and routes models through the matching SDK/API shape:
126
133
 
127
134
  - `chat_completions` / `openai-completions` → OpenAI-compatible chat completions
@@ -132,10 +139,12 @@ The OpenCode plugin respects each model's `preferred_api` value and routes model
132
139
  You can also pre-configure via environment variables:
133
140
 
134
141
  ```sh
135
- export PLEXUS_BASE_URL=https://plexus.example.com/v1
142
+ export PLEXUS_API_URL=https://plexus.example.com
136
143
  export PLEXUS_API_KEY=your-api-key
137
144
  ```
138
145
 
146
+ `PLEXUS_BASE_URL` is also accepted for compatibility; `PLEXUS_API_URL` wins when both are set.
147
+
139
148
  ---
140
149
 
141
150
  ## Configuration files
@@ -150,7 +159,7 @@ export PLEXUS_API_KEY=your-api-key
150
159
  plexus.log # extension activity log
151
160
  ```
152
161
 
153
- The API key is stored in pi's own credential store (`auth.json`) never in a separate file.
162
+ The API key is stored through pi's own auth storage. `PLEXUS_API_URL` or `PLEXUS_BASE_URL` can be used as an environment override.
154
163
 
155
164
  ### OpenCode
156
165
 
@@ -160,7 +169,9 @@ The API key is stored in pi's own credential store (`auth.json`) — never in a
160
169
  models-raw.json # raw API response (diagnostics)
161
170
  ```
162
171
 
163
- The API key is stored in OpenCode's own credential store never in a separate file.
172
+ The API key is stored through OpenCode's auth flow, and the Plexus base URL is stored as auth metadata. `PLEXUS_API_URL`, `PLEXUS_BASE_URL`, and `PLEXUS_API_KEY` can be used as environment overrides.
173
+
174
+ Both adapters also accept pi-style environment interpolation in configured strings, such as `${PLEXUS_API_URL}` or `$PLEXUS_API_KEY`. This is useful when checking non-secret config into an agent config file while keeping the actual values in the environment.
164
175
 
165
176
  ---
166
177
 
@@ -183,7 +194,7 @@ packages/
183
194
  package.json # declares pi.extensions entry point
184
195
  plexus-opencode/ # OpenCode plugin adapter
185
196
  src/
186
- plugin.ts # Plugin export: config hook, auth handler
197
+ plugin.ts # Plugin export: config hook, provider hook, auth handler
187
198
  mapper.ts # PlexusApiModel → OpenCode ConfigModel
188
199
  cache.ts # model cache I/O
189
200
  config-store.ts # resolveConfig, persistToGlobalConfig
@@ -196,6 +207,30 @@ packages/
196
207
 
197
208
  `plexus-models` has zero imports from any agent framework. Each host adapter imports it via a relative path.
198
209
 
210
+ ## Plexus model metadata
211
+
212
+ The `/v1/models` endpoint returns an OpenRouter-style list. These fields drive host behavior:
213
+
214
+ | Field | Effect |
215
+ |---|---|
216
+ | `preferred_api` | String or array. First recognized value selects the host API dialect. |
217
+ | `supported_parameters` | `reasoning`, `include_reasoning`, or `reasoning_effort` enables reasoning support. |
218
+ | `architecture.input_modalities` | Enables text/image/audio/video/pdf support where the host supports it. |
219
+ | `architecture.output_modalities` | Non-text-only output models are filtered from OpenCode chat providers. |
220
+ | `pricing` | Converted into host per-million-token cost metadata. Plexus returns per-token prices. |
221
+ | `pricing.tiers` | Alternate rates above `input_tokens_above`; mapped to native pi and OpenCode context pricing tiers. |
222
+ | `top_provider` | Supplies context and output token limits when present. |
223
+ | `pi_provider` / `pi_model` | Lets the pi adapter reuse built-in pi compat, headers, and thinking-level metadata. |
224
+ | `pi_options` | pi compat overrides. These win over heuristic and built-in metadata. |
225
+
226
+ Models with a falsy `id` are skipped. Missing metadata falls back to safe defaults.
227
+
228
+ ## Adapter behavior
229
+
230
+ - **pi** refreshes on session start and through `/plexus refresh`. It accepts either root URLs or URLs ending in `/v1` and normalizes them before calling Plexus.
231
+ - **OpenCode** seeds the provider from cache or a placeholder model during config loading, then performs live discovery through the `provider.models` hook. If Plexus is slow or unavailable, OpenCode uses the cache and lets the refresh continue in the background.
232
+ - Both adapters convert Plexus's per-token base and tier rates to the per-million-token units expected by their host.
233
+
199
234
  ## Development
200
235
 
201
236
  After cloning, install dependencies to set up the pre-commit hook:
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ var PLEXUS_LOG_SERVICE = "opencode-plexus";
7
7
  var OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible";
8
8
  var PLEXUS_BASE_URL_OPTION = "plexusBaseURL";
9
9
  var ENV_BASE_URL = "PLEXUS_BASE_URL";
10
+ var ENV_API_URL = "PLEXUS_API_URL";
10
11
  var ENV_API_KEY = "PLEXUS_API_KEY";
11
12
  var MODELS_FETCH_TIMEOUT_MS = 1e4;
12
13
  var REFRESH_TTL_MS = 60000;
@@ -52,11 +53,11 @@ async function fetchPlexusModels(apiKey, modelsUrl, timeoutMs = DEFAULT_MODELS_F
52
53
  const controller = new AbortController;
53
54
  const timer = setTimeout(() => controller.abort(), timeoutMs);
54
55
  try {
56
+ const headers = { Accept: "application/json" };
57
+ if (apiKey)
58
+ headers.Authorization = `Bearer ${apiKey}`;
55
59
  const res = await fetch(modelsUrl, {
56
- headers: {
57
- Authorization: `Bearer ${apiKey}`,
58
- Accept: "application/json"
59
- },
60
+ headers,
60
61
  signal: controller.signal
61
62
  });
62
63
  if (!res.ok) {
@@ -74,35 +75,21 @@ async function fetchPlexusModels(apiKey, modelsUrl, timeoutMs = DEFAULT_MODELS_F
74
75
  }
75
76
  }
76
77
  // src/cache.ts
77
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
78
78
  import { mkdir, readFile, writeFile } from "fs/promises";
79
79
  import { homedir } from "os";
80
80
  import { join } from "path";
81
81
  var PLUGIN_SUBDIR = join("plugins", "plexus");
82
82
  var CACHE_FILE = "models-cache.json";
83
83
  var RAW_FILE = "models-raw.json";
84
- var resolvedDir = null;
85
84
  function fallbackDir() {
86
85
  return join(homedir(), ".local", "share", "opencode", PLUGIN_SUBDIR);
87
86
  }
88
- async function getDir(client) {
89
- if (resolvedDir)
90
- return resolvedDir;
91
- try {
92
- const res = await client.path.get();
93
- const data = res?.data;
94
- const state = typeof data?.state === "string" && data.state ? data.state : undefined;
95
- if (state) {
96
- resolvedDir = join(state, PLUGIN_SUBDIR);
97
- return resolvedDir;
98
- }
99
- } catch {}
100
- resolvedDir = fallbackDir();
101
- return resolvedDir;
87
+ function getDir() {
88
+ return fallbackDir();
102
89
  }
103
- async function readCachedModels(client) {
90
+ async function readCachedModels(_client) {
104
91
  try {
105
- const dir = await getDir(client);
92
+ const dir = getDir();
106
93
  const content = await readFile(join(dir, CACHE_FILE), "utf8");
107
94
  const parsed = JSON.parse(content);
108
95
  if (parsed && typeof parsed.models === "object" && !Array.isArray(parsed.models)) {
@@ -113,9 +100,9 @@ async function readCachedModels(client) {
113
100
  return null;
114
101
  }
115
102
  }
116
- async function writeCache(client, models, raw) {
103
+ async function writeCache(_client, models, raw) {
117
104
  try {
118
- const dir = await getDir(client);
105
+ const dir = getDir();
119
106
  await mkdir(dir, { recursive: true });
120
107
  const cache = { models, timestamp: Date.now() };
121
108
  await writeFile(join(dir, CACHE_FILE), JSON.stringify(cache, null, 2) + `
@@ -124,27 +111,24 @@ async function writeCache(client, models, raw) {
124
111
  await writeFile(join(dir, RAW_FILE), JSON.stringify(raw, null, 2) + `
125
112
  `, "utf8");
126
113
  }
127
- try {
128
- const syncDir = fallbackDir();
129
- mkdirSync(syncDir, { recursive: true });
130
- writeFileSync(join(syncDir, CACHE_FILE), JSON.stringify(cache, null, 2) + `
131
- `, "utf8");
132
- } catch {}
133
114
  } catch {}
134
115
  }
135
116
 
136
- // src/config-store.ts
137
- import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
138
-
139
117
  // src/url.ts
140
118
  function trimURL(s) {
141
119
  return s.trim().replace(/\/+$/, "");
142
120
  }
121
+ function rootURL(s) {
122
+ const next = trimURL(s);
123
+ if (!next)
124
+ return "";
125
+ return next.endsWith("/v1") ? next.slice(0, -3) : next;
126
+ }
143
127
  function apiBase(baseURL) {
144
- const next = trimURL(baseURL);
128
+ const next = rootURL(baseURL);
145
129
  if (!next)
146
130
  return "";
147
- return next.endsWith("/v1") ? next : `${next}/v1`;
131
+ return `${next}/v1`;
148
132
  }
149
133
  function modelsUrl(baseURL) {
150
134
  const base = apiBase(baseURL);
@@ -152,40 +136,76 @@ function modelsUrl(baseURL) {
152
136
  }
153
137
 
154
138
  // src/config-store.ts
155
- function getV1ClientConfig(input) {
156
- return input._client?.getConfig?.() ?? {};
139
+ var ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
140
+ var ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
141
+ var AUTH_METADATA_BASE_URL = "plexusBaseURL";
142
+ function resolveConfigTemplate(value) {
143
+ let result = "";
144
+ let index = 0;
145
+ while (index < value.length) {
146
+ const dollarIndex = value.indexOf("$", index);
147
+ if (dollarIndex < 0) {
148
+ result += value.slice(index);
149
+ break;
150
+ }
151
+ result += value.slice(index, dollarIndex);
152
+ const nextChar = value[dollarIndex + 1];
153
+ if (nextChar === "$" || nextChar === "!") {
154
+ result += nextChar;
155
+ index = dollarIndex + 2;
156
+ continue;
157
+ }
158
+ if (nextChar === "{") {
159
+ const endIndex = value.indexOf("}", dollarIndex + 2);
160
+ if (endIndex < 0) {
161
+ result += "$";
162
+ index = dollarIndex + 1;
163
+ continue;
164
+ }
165
+ const name = value.slice(dollarIndex + 2, endIndex);
166
+ if (!ENV_VAR_NAME_RE.test(name)) {
167
+ result += value.slice(dollarIndex, endIndex + 1);
168
+ index = endIndex + 1;
169
+ continue;
170
+ }
171
+ const envValue = process.env[name];
172
+ if (envValue === undefined)
173
+ return;
174
+ result += envValue;
175
+ index = endIndex + 1;
176
+ continue;
177
+ }
178
+ const match = value.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_RE);
179
+ if (match) {
180
+ const envValue = process.env[match[0]];
181
+ if (envValue === undefined)
182
+ return;
183
+ result += envValue;
184
+ index = dollarIndex + 1 + match[0].length;
185
+ continue;
186
+ }
187
+ result += "$";
188
+ index = dollarIndex + 1;
189
+ }
190
+ return result;
157
191
  }
158
- function createV2Client(serverUrl, input) {
159
- const v1Config = getV1ClientConfig(input);
160
- return createOpencodeClient({
161
- baseUrl: serverUrl.toString(),
162
- fetch: v1Config.fetch,
163
- headers: v1Config.headers,
164
- throwOnError: true
165
- });
192
+ function resolveStringOption(value) {
193
+ if (typeof value !== "string")
194
+ return;
195
+ const resolved = resolveConfigTemplate(value)?.trim();
196
+ return resolved || undefined;
166
197
  }
167
- function resolveConfig(provider) {
168
- const envBaseURL = process.env[ENV_BASE_URL];
198
+ function resolveConfig(provider, authMetadata) {
199
+ const envBaseURL = process.env[ENV_API_URL] ?? process.env[ENV_BASE_URL];
169
200
  const envApiKey = process.env[ENV_API_KEY];
170
- const optBaseURL = typeof provider?.options?.[PLEXUS_BASE_URL_OPTION] === "string" ? trimURL(provider.options[PLEXUS_BASE_URL_OPTION]) : undefined;
171
- const legacyBaseURL = typeof provider?.options?.baseURL === "string" ? trimURL(provider.options.baseURL) : undefined;
172
- const optApiKey = typeof provider?.options?.apiKey === "string" ? provider.options.apiKey.trim() : undefined;
173
- const baseURL = (envBaseURL ? trimURL(envBaseURL) : undefined) || optBaseURL || legacyBaseURL || undefined;
201
+ const authBaseURL = resolveStringOption(authMetadata?.[AUTH_METADATA_BASE_URL]);
202
+ const optBaseURL = resolveStringOption(provider?.options?.[PLEXUS_BASE_URL_OPTION]);
203
+ const legacyBaseURL = resolveStringOption(provider?.options?.baseURL);
204
+ const optApiKey = resolveStringOption(provider?.options?.apiKey);
205
+ const baseURL = (envBaseURL ? rootURL(envBaseURL) : undefined) || (authBaseURL ? rootURL(authBaseURL) : undefined) || (optBaseURL ? rootURL(optBaseURL) : undefined) || (legacyBaseURL ? rootURL(legacyBaseURL) : undefined) || undefined;
174
206
  const apiKey = (envApiKey ? envApiKey.trim() : undefined) || optApiKey || undefined;
175
207
  return { baseURL: baseURL || undefined, apiKey: apiKey || undefined };
176
208
  }
177
- async function persistToGlobalConfig(serverUrl, client, baseURL, apiKey) {
178
- const v2 = createV2Client(serverUrl, client);
179
- await v2.global.config.update({
180
- config: {
181
- provider: {
182
- [PLEXUS_PROVIDER_ID]: {
183
- options: { [PLEXUS_BASE_URL_OPTION]: baseURL, apiKey }
184
- }
185
- }
186
- }
187
- });
188
- }
189
209
 
190
210
  // src/log.ts
191
211
  function createLogger(client) {
@@ -202,6 +222,7 @@ function createLogger(client) {
202
222
  // src/mapper.ts
203
223
  var REASONING_PARAMS2 = new Set(["reasoning", "include_reasoning", "reasoning_effort"]);
204
224
  var DEFAULT_CONTEXT = 8192;
225
+ var PER_TOKEN_TO_PER_MILLION = 1e6;
205
226
  function resolveModelProvider(model, baseURL) {
206
227
  const preferredApi = mapPreferredApi(model.preferred_api);
207
228
  const api = adjustBaseUrl(baseURL, preferredApi);
@@ -222,7 +243,24 @@ function parsePrice(value) {
222
243
  if (!value)
223
244
  return 0;
224
245
  const n = parseFloat(value);
225
- return Number.isNaN(n) ? 0 : n;
246
+ return Number.isFinite(n) && n >= 0 ? n * PER_TOKEN_TO_PER_MILLION : 0;
247
+ }
248
+ function buildPricingTiers(model) {
249
+ const pricing = model.pricing;
250
+ if (!pricing?.tiers)
251
+ return;
252
+ const tiers = pricing.tiers.flatMap((tier) => {
253
+ if (!Number.isFinite(tier.input_tokens_above) || tier.input_tokens_above < 0)
254
+ return [];
255
+ return [{
256
+ inputTokensAbove: tier.input_tokens_above,
257
+ input: parsePrice(tier.prompt ?? pricing.prompt),
258
+ output: parsePrice(tier.completion ?? pricing.completion),
259
+ cacheRead: parsePrice(tier.input_cache_read ?? pricing.input_cache_read),
260
+ cacheWrite: parsePrice(tier.input_cache_write ?? pricing.input_cache_write)
261
+ }];
262
+ });
263
+ return tiers.length > 0 ? tiers : undefined;
226
264
  }
227
265
  function mapModality(m) {
228
266
  switch (m) {
@@ -276,6 +314,7 @@ function buildModels(models, baseURL) {
276
314
  const cacheReadPrice = parsePrice(m.pricing?.input_cache_read);
277
315
  const cacheWritePrice = parsePrice(m.pricing?.input_cache_write);
278
316
  const hasCachePricing = cacheReadPrice > 0 || cacheWritePrice > 0;
317
+ const pricingTiers = buildPricingTiers(m);
279
318
  const hasNonTextInput = inputModalities.some((mod) => mod !== "text");
280
319
  const provider = resolveModelProvider(m, baseURL);
281
320
  const entry = {
@@ -300,7 +339,8 @@ function buildModels(models, baseURL) {
300
339
  ...params.includes("tools") ? { tool_call: true } : {},
301
340
  ...params.some((p) => REASONING_PARAMS2.has(p)) ? { reasoning: true } : {},
302
341
  ...params.includes("temperature") ? { temperature: true } : {},
303
- ...hasNonTextInput ? { attachment: true } : {}
342
+ ...hasNonTextInput ? { attachment: true } : {},
343
+ ...pricingTiers ? { pricingTiers } : {}
304
344
  };
305
345
  result[m.id] = entry;
306
346
  }
@@ -310,6 +350,80 @@ function buildModels(models, baseURL) {
310
350
  // src/plugin.ts
311
351
  var lastRefresh = null;
312
352
  var inFlightRefresh = null;
353
+ function toRuntimeCapabilities(model) {
354
+ const input = new Set(model.modalities.input);
355
+ const output = new Set(model.modalities.output);
356
+ return {
357
+ temperature: model.temperature ?? false,
358
+ reasoning: model.reasoning ?? false,
359
+ attachment: model.attachment ?? false,
360
+ toolcall: model.tool_call ?? false,
361
+ input: {
362
+ text: input.has("text"),
363
+ audio: input.has("audio"),
364
+ image: input.has("image"),
365
+ video: input.has("video"),
366
+ pdf: input.has("pdf")
367
+ },
368
+ output: {
369
+ text: output.has("text"),
370
+ audio: output.has("audio"),
371
+ image: output.has("image"),
372
+ video: output.has("video"),
373
+ pdf: output.has("pdf")
374
+ },
375
+ interleaved: false
376
+ };
377
+ }
378
+ function toRuntimeModels(models, provider) {
379
+ const result = {};
380
+ const providerNpm = typeof provider.options?.["npm"] === "string" ? provider.options["npm"] : OPENAI_COMPATIBLE_NPM;
381
+ const providerApi = typeof provider.options?.[PLEXUS_BASE_URL_OPTION] === "string" ? apiBase(provider.options[PLEXUS_BASE_URL_OPTION]) : "";
382
+ for (const [id, model] of Object.entries(models)) {
383
+ result[id] = {
384
+ id: model.id,
385
+ providerID: provider.id,
386
+ api: {
387
+ id: provider.id,
388
+ url: model.provider?.api ?? providerApi,
389
+ npm: model.provider?.npm ?? providerNpm
390
+ },
391
+ name: model.name,
392
+ capabilities: toRuntimeCapabilities(model),
393
+ cost: {
394
+ input: model.cost?.input ?? 0,
395
+ output: model.cost?.output ?? 0,
396
+ cache: {
397
+ read: model.cost?.cache_read ?? 0,
398
+ write: model.cost?.cache_write ?? 0
399
+ },
400
+ ...model.pricingTiers ? {
401
+ tiers: model.pricingTiers.map((tier) => ({
402
+ input: tier.input,
403
+ output: tier.output,
404
+ cache: { read: tier.cacheRead, write: tier.cacheWrite },
405
+ tier: { type: "context", size: tier.inputTokensAbove }
406
+ }))
407
+ } : {}
408
+ },
409
+ limit: model.limit,
410
+ status: "active",
411
+ options: {},
412
+ headers: {},
413
+ release_date: ""
414
+ };
415
+ }
416
+ return result;
417
+ }
418
+ function toConfigModels(models) {
419
+ return Object.fromEntries(Object.entries(models).map(([id, model]) => {
420
+ const { pricingTiers: _pricingTiers, ...configModel } = model;
421
+ return [id, configModel];
422
+ }));
423
+ }
424
+ function authMetadata(auth) {
425
+ return auth?.type === "api" ? auth.metadata : undefined;
426
+ }
313
427
  function raceWithTimeout(promise, timeoutMs) {
314
428
  return new Promise((resolve) => {
315
429
  const timer = setTimeout(() => resolve({ status: "timed-out" }), timeoutMs);
@@ -322,41 +436,6 @@ function raceWithTimeout(promise, timeoutMs) {
322
436
  });
323
437
  });
324
438
  }
325
- function mergeModelMaps(base, overrides) {
326
- if (!overrides)
327
- return base;
328
- const merged = { ...base };
329
- for (const [id, override] of Object.entries(overrides)) {
330
- const existing = merged[id];
331
- if (!existing) {
332
- merged[id] = override;
333
- continue;
334
- }
335
- merged[id] = {
336
- ...existing,
337
- ...override,
338
- provider: {
339
- ...existing.provider ?? {},
340
- ...override.provider ?? {}
341
- },
342
- ...existing.cost || override.cost ? {
343
- cost: {
344
- ...existing.cost ?? { input: 0, output: 0 },
345
- ...override.cost ?? {}
346
- }
347
- } : {},
348
- limit: {
349
- ...existing.limit,
350
- ...override.limit
351
- },
352
- modalities: {
353
- input: override.modalities?.input ?? existing.modalities.input,
354
- output: override.modalities?.output ?? existing.modalities.output
355
- }
356
- };
357
- }
358
- return merged;
359
- }
360
439
  function refreshModels(client, baseURL, log, apiKey) {
361
440
  if (lastRefresh && Date.now() - lastRefresh.at < REFRESH_TTL_MS) {
362
441
  log.info(`Using in-memory plexus model cache (${Object.keys(lastRefresh.models).length} models)`);
@@ -409,7 +488,7 @@ var PlexusProviderPlugin = async (ctx) => {
409
488
  ...baseURL ? { [PLEXUS_BASE_URL_OPTION]: baseURL } : {},
410
489
  ...apiKey ? { apiKey } : {}
411
490
  },
412
- models: existingModels ?? cachedAsync ?? {
491
+ models: existingModels ?? (cachedAsync ? toConfigModels(cachedAsync) : null) ?? {
413
492
  [PLACEHOLDER_MODEL_ID]: {
414
493
  id: PLACEHOLDER_MODEL_ID,
415
494
  name: "Plexus (run /connect to configure)",
@@ -421,25 +500,7 @@ var PlexusProviderPlugin = async (ctx) => {
421
500
  const mergedOptions = merged["options"];
422
501
  delete mergedOptions["baseURL"];
423
502
  if (baseURL) {
424
- const refreshPromise = refreshModels(client, baseURL, log, apiKey);
425
- const race = await raceWithTimeout(refreshPromise, CONFIG_HOOK_REFRESH_BUDGET_MS);
426
- if (race.status === "resolved") {
427
- merged["models"] = mergeModelMaps(race.value, existingModels);
428
- log.info(`Loaded ${Object.keys(race.value).length} plexus models from ${baseURL}`);
429
- } else if (race.status === "rejected") {
430
- log.warn(`Live model refresh failed, using cache: ${String(race.error)}`);
431
- if (cachedAsync) {
432
- merged["models"] = mergeModelMaps(cachedAsync, existingModels);
433
- }
434
- } else {
435
- log.info(`Live model refresh still pending after ${CONFIG_HOOK_REFRESH_BUDGET_MS}ms; using cache and continuing in background`);
436
- if (cachedAsync) {
437
- merged["models"] = mergeModelMaps(cachedAsync, existingModels);
438
- }
439
- refreshPromise.catch((e) => {
440
- log.warn(`Background plexus model refresh failed: ${String(e)}`);
441
- });
442
- }
503
+ log.info("Plexus baseURL configured; live discovery delegated to provider.models hook");
443
504
  } else {
444
505
  log.info("Plexus baseURL not configured; skipping live refresh");
445
506
  }
@@ -454,11 +515,41 @@ var PlexusProviderPlugin = async (ctx) => {
454
515
  } catch {}
455
516
  cfg.provider[PLEXUS_PROVIDER_ID] = merged;
456
517
  },
518
+ provider: {
519
+ id: PLEXUS_PROVIDER_ID,
520
+ models: async (provider, hookCtx) => {
521
+ const authKey = hookCtx.auth?.type === "api" ? hookCtx.auth.key : undefined;
522
+ const { baseURL, apiKey } = resolveConfig(provider, authMetadata(hookCtx.auth));
523
+ const key = authKey ?? apiKey;
524
+ if (!baseURL) {
525
+ log.info("Provider hook skipped live refresh; baseURL missing");
526
+ const cached2 = await readCachedModels(client);
527
+ return cached2 ? toRuntimeModels(cached2, provider) : {};
528
+ }
529
+ const refreshPromise = refreshModels(client, baseURL, log, key);
530
+ const race = await raceWithTimeout(refreshPromise, CONFIG_HOOK_REFRESH_BUDGET_MS);
531
+ if (race.status === "resolved") {
532
+ log.info(`Provider hook loaded ${Object.keys(race.value).length} plexus models from ${baseURL}`);
533
+ return toRuntimeModels(race.value, provider);
534
+ }
535
+ const cached = await readCachedModels(client);
536
+ if (race.status === "rejected") {
537
+ log.warn(`Provider hook live refresh failed, using cache: ${String(race.error)}`);
538
+ } else {
539
+ log.info(`Provider hook refresh still pending after ${CONFIG_HOOK_REFRESH_BUDGET_MS}ms; using cache and continuing in background`);
540
+ refreshPromise.catch((e) => {
541
+ log.warn(`Background plexus model refresh failed: ${String(e)}`);
542
+ });
543
+ }
544
+ return cached ? toRuntimeModels(cached, provider) : {};
545
+ }
546
+ },
457
547
  auth: {
458
548
  provider: PLEXUS_PROVIDER_ID,
459
549
  async loader(getAuth, providerInfo) {
460
550
  const auth = await getAuth();
461
- const { baseURL, apiKey } = resolveConfig(providerInfo);
551
+ const authMetadataValue = auth?.type === "api" ? auth.metadata : undefined;
552
+ const { baseURL, apiKey } = resolveConfig(providerInfo, authMetadataValue);
462
553
  const key = (auth?.type === "api" ? auth.key : undefined) ?? apiKey;
463
554
  log.info(`Auth loader resolved plexus config: baseURL=${baseURL ?? "(missing)"} apiKey=${key ? "present" : "missing"}`);
464
555
  return {
@@ -479,7 +570,7 @@ var PlexusProviderPlugin = async (ctx) => {
479
570
  }
480
571
  ],
481
572
  async authorize(inputs = {}) {
482
- const baseURL = trimURL(inputs["baseURL"] ?? "");
573
+ const baseURL = rootURL(inputs["baseURL"] ?? "");
483
574
  const apiKey = (inputs["apiKey"] ?? "").trim();
484
575
  if (!baseURL || !apiKey)
485
576
  return { type: "failed" };
@@ -490,13 +581,13 @@ var PlexusProviderPlugin = async (ctx) => {
490
581
  log.error(`Plexus URL probe failed at ${baseURL}: ${String(e)}`);
491
582
  return { type: "failed" };
492
583
  }
493
- try {
494
- await persistToGlobalConfig(ctx.serverUrl, client, baseURL, apiKey);
495
- } catch (e) {
496
- log.error(`Failed to persist Plexus config: ${String(e)}`);
497
- }
498
584
  lastRefresh = null;
499
- return { type: "success", provider: PLEXUS_PROVIDER_ID, key: apiKey };
585
+ return {
586
+ type: "success",
587
+ provider: PLEXUS_PROVIDER_ID,
588
+ key: apiKey,
589
+ metadata: { [AUTH_METADATA_BASE_URL]: baseURL }
590
+ };
500
591
  }
501
592
  }
502
593
  ]
@@ -524,6 +615,7 @@ export {
524
615
  OPENAI_COMPATIBLE_NPM,
525
616
  MODELS_FETCH_TIMEOUT_MS,
526
617
  ENV_BASE_URL,
618
+ ENV_API_URL,
527
619
  ENV_API_KEY,
528
620
  CONFIG_HOOK_REFRESH_BUDGET_MS
529
621
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcowger/opencode-plexus",
3
- "version": "0.8.3",
3
+ "version": "1.0.0",
4
4
  "description": "OpenCode plugin: Plexus provider with dynamic model discovery",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -26,8 +26,8 @@
26
26
  "build": "bun run build.ts"
27
27
  },
28
28
  "dependencies": {
29
- "@opencode-ai/plugin": "^1.15.7",
30
- "@opencode-ai/sdk": "^1.15.7"
29
+ "@opencode-ai/plugin": "^1.17.18",
30
+ "@opencode-ai/sdk": "^1.17.18"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "typescript": "^5"