@mcowger/opencode-plexus 0.8.4 → 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 +214 -102
  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) {
@@ -113,18 +114,21 @@ async function writeCache(_client, models, raw) {
113
114
  } catch {}
114
115
  }
115
116
 
116
- // src/config-store.ts
117
- import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
118
-
119
117
  // src/url.ts
120
118
  function trimURL(s) {
121
119
  return s.trim().replace(/\/+$/, "");
122
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
+ }
123
127
  function apiBase(baseURL) {
124
- const next = trimURL(baseURL);
128
+ const next = rootURL(baseURL);
125
129
  if (!next)
126
130
  return "";
127
- return next.endsWith("/v1") ? next : `${next}/v1`;
131
+ return `${next}/v1`;
128
132
  }
129
133
  function modelsUrl(baseURL) {
130
134
  const base = apiBase(baseURL);
@@ -132,40 +136,76 @@ function modelsUrl(baseURL) {
132
136
  }
133
137
 
134
138
  // src/config-store.ts
135
- function getV1ClientConfig(input) {
136
- 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;
137
191
  }
138
- function createV2Client(serverUrl, input) {
139
- const v1Config = getV1ClientConfig(input);
140
- return createOpencodeClient({
141
- baseUrl: serverUrl.toString(),
142
- fetch: v1Config.fetch,
143
- headers: v1Config.headers,
144
- throwOnError: true
145
- });
192
+ function resolveStringOption(value) {
193
+ if (typeof value !== "string")
194
+ return;
195
+ const resolved = resolveConfigTemplate(value)?.trim();
196
+ return resolved || undefined;
146
197
  }
147
- function resolveConfig(provider) {
148
- 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];
149
200
  const envApiKey = process.env[ENV_API_KEY];
150
- const optBaseURL = typeof provider?.options?.[PLEXUS_BASE_URL_OPTION] === "string" ? trimURL(provider.options[PLEXUS_BASE_URL_OPTION]) : undefined;
151
- const legacyBaseURL = typeof provider?.options?.baseURL === "string" ? trimURL(provider.options.baseURL) : undefined;
152
- const optApiKey = typeof provider?.options?.apiKey === "string" ? provider.options.apiKey.trim() : undefined;
153
- 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;
154
206
  const apiKey = (envApiKey ? envApiKey.trim() : undefined) || optApiKey || undefined;
155
207
  return { baseURL: baseURL || undefined, apiKey: apiKey || undefined };
156
208
  }
157
- async function persistToGlobalConfig(serverUrl, client, baseURL, apiKey) {
158
- const v2 = createV2Client(serverUrl, client);
159
- await v2.global.config.update({
160
- config: {
161
- provider: {
162
- [PLEXUS_PROVIDER_ID]: {
163
- options: { [PLEXUS_BASE_URL_OPTION]: baseURL, apiKey }
164
- }
165
- }
166
- }
167
- });
168
- }
169
209
 
170
210
  // src/log.ts
171
211
  function createLogger(client) {
@@ -182,6 +222,7 @@ function createLogger(client) {
182
222
  // src/mapper.ts
183
223
  var REASONING_PARAMS2 = new Set(["reasoning", "include_reasoning", "reasoning_effort"]);
184
224
  var DEFAULT_CONTEXT = 8192;
225
+ var PER_TOKEN_TO_PER_MILLION = 1e6;
185
226
  function resolveModelProvider(model, baseURL) {
186
227
  const preferredApi = mapPreferredApi(model.preferred_api);
187
228
  const api = adjustBaseUrl(baseURL, preferredApi);
@@ -202,7 +243,24 @@ function parsePrice(value) {
202
243
  if (!value)
203
244
  return 0;
204
245
  const n = parseFloat(value);
205
- 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;
206
264
  }
207
265
  function mapModality(m) {
208
266
  switch (m) {
@@ -256,6 +314,7 @@ function buildModels(models, baseURL) {
256
314
  const cacheReadPrice = parsePrice(m.pricing?.input_cache_read);
257
315
  const cacheWritePrice = parsePrice(m.pricing?.input_cache_write);
258
316
  const hasCachePricing = cacheReadPrice > 0 || cacheWritePrice > 0;
317
+ const pricingTiers = buildPricingTiers(m);
259
318
  const hasNonTextInput = inputModalities.some((mod) => mod !== "text");
260
319
  const provider = resolveModelProvider(m, baseURL);
261
320
  const entry = {
@@ -280,7 +339,8 @@ function buildModels(models, baseURL) {
280
339
  ...params.includes("tools") ? { tool_call: true } : {},
281
340
  ...params.some((p) => REASONING_PARAMS2.has(p)) ? { reasoning: true } : {},
282
341
  ...params.includes("temperature") ? { temperature: true } : {},
283
- ...hasNonTextInput ? { attachment: true } : {}
342
+ ...hasNonTextInput ? { attachment: true } : {},
343
+ ...pricingTiers ? { pricingTiers } : {}
284
344
  };
285
345
  result[m.id] = entry;
286
346
  }
@@ -290,6 +350,80 @@ function buildModels(models, baseURL) {
290
350
  // src/plugin.ts
291
351
  var lastRefresh = null;
292
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
+ }
293
427
  function raceWithTimeout(promise, timeoutMs) {
294
428
  return new Promise((resolve) => {
295
429
  const timer = setTimeout(() => resolve({ status: "timed-out" }), timeoutMs);
@@ -302,41 +436,6 @@ function raceWithTimeout(promise, timeoutMs) {
302
436
  });
303
437
  });
304
438
  }
305
- function mergeModelMaps(base, overrides) {
306
- if (!overrides)
307
- return base;
308
- const merged = { ...base };
309
- for (const [id, override] of Object.entries(overrides)) {
310
- const existing = merged[id];
311
- if (!existing) {
312
- merged[id] = override;
313
- continue;
314
- }
315
- merged[id] = {
316
- ...existing,
317
- ...override,
318
- provider: {
319
- ...existing.provider ?? {},
320
- ...override.provider ?? {}
321
- },
322
- ...existing.cost || override.cost ? {
323
- cost: {
324
- ...existing.cost ?? { input: 0, output: 0 },
325
- ...override.cost ?? {}
326
- }
327
- } : {},
328
- limit: {
329
- ...existing.limit,
330
- ...override.limit
331
- },
332
- modalities: {
333
- input: override.modalities?.input ?? existing.modalities.input,
334
- output: override.modalities?.output ?? existing.modalities.output
335
- }
336
- };
337
- }
338
- return merged;
339
- }
340
439
  function refreshModels(client, baseURL, log, apiKey) {
341
440
  if (lastRefresh && Date.now() - lastRefresh.at < REFRESH_TTL_MS) {
342
441
  log.info(`Using in-memory plexus model cache (${Object.keys(lastRefresh.models).length} models)`);
@@ -389,7 +488,7 @@ var PlexusProviderPlugin = async (ctx) => {
389
488
  ...baseURL ? { [PLEXUS_BASE_URL_OPTION]: baseURL } : {},
390
489
  ...apiKey ? { apiKey } : {}
391
490
  },
392
- models: existingModels ?? cachedAsync ?? {
491
+ models: existingModels ?? (cachedAsync ? toConfigModels(cachedAsync) : null) ?? {
393
492
  [PLACEHOLDER_MODEL_ID]: {
394
493
  id: PLACEHOLDER_MODEL_ID,
395
494
  name: "Plexus (run /connect to configure)",
@@ -401,25 +500,7 @@ var PlexusProviderPlugin = async (ctx) => {
401
500
  const mergedOptions = merged["options"];
402
501
  delete mergedOptions["baseURL"];
403
502
  if (baseURL) {
404
- const refreshPromise = refreshModels(client, baseURL, log, apiKey);
405
- const race = await raceWithTimeout(refreshPromise, CONFIG_HOOK_REFRESH_BUDGET_MS);
406
- if (race.status === "resolved") {
407
- merged["models"] = mergeModelMaps(race.value, existingModels);
408
- log.info(`Loaded ${Object.keys(race.value).length} plexus models from ${baseURL}`);
409
- } else if (race.status === "rejected") {
410
- log.warn(`Live model refresh failed, using cache: ${String(race.error)}`);
411
- if (cachedAsync) {
412
- merged["models"] = mergeModelMaps(cachedAsync, existingModels);
413
- }
414
- } else {
415
- log.info(`Live model refresh still pending after ${CONFIG_HOOK_REFRESH_BUDGET_MS}ms; using cache and continuing in background`);
416
- if (cachedAsync) {
417
- merged["models"] = mergeModelMaps(cachedAsync, existingModels);
418
- }
419
- refreshPromise.catch((e) => {
420
- log.warn(`Background plexus model refresh failed: ${String(e)}`);
421
- });
422
- }
503
+ log.info("Plexus baseURL configured; live discovery delegated to provider.models hook");
423
504
  } else {
424
505
  log.info("Plexus baseURL not configured; skipping live refresh");
425
506
  }
@@ -434,11 +515,41 @@ var PlexusProviderPlugin = async (ctx) => {
434
515
  } catch {}
435
516
  cfg.provider[PLEXUS_PROVIDER_ID] = merged;
436
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
+ },
437
547
  auth: {
438
548
  provider: PLEXUS_PROVIDER_ID,
439
549
  async loader(getAuth, providerInfo) {
440
550
  const auth = await getAuth();
441
- const { baseURL, apiKey } = resolveConfig(providerInfo);
551
+ const authMetadataValue = auth?.type === "api" ? auth.metadata : undefined;
552
+ const { baseURL, apiKey } = resolveConfig(providerInfo, authMetadataValue);
442
553
  const key = (auth?.type === "api" ? auth.key : undefined) ?? apiKey;
443
554
  log.info(`Auth loader resolved plexus config: baseURL=${baseURL ?? "(missing)"} apiKey=${key ? "present" : "missing"}`);
444
555
  return {
@@ -459,7 +570,7 @@ var PlexusProviderPlugin = async (ctx) => {
459
570
  }
460
571
  ],
461
572
  async authorize(inputs = {}) {
462
- const baseURL = trimURL(inputs["baseURL"] ?? "");
573
+ const baseURL = rootURL(inputs["baseURL"] ?? "");
463
574
  const apiKey = (inputs["apiKey"] ?? "").trim();
464
575
  if (!baseURL || !apiKey)
465
576
  return { type: "failed" };
@@ -470,13 +581,13 @@ var PlexusProviderPlugin = async (ctx) => {
470
581
  log.error(`Plexus URL probe failed at ${baseURL}: ${String(e)}`);
471
582
  return { type: "failed" };
472
583
  }
473
- try {
474
- await persistToGlobalConfig(ctx.serverUrl, client, baseURL, apiKey);
475
- } catch (e) {
476
- log.error(`Failed to persist Plexus config: ${String(e)}`);
477
- }
478
584
  lastRefresh = null;
479
- 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
+ };
480
591
  }
481
592
  }
482
593
  ]
@@ -504,6 +615,7 @@ export {
504
615
  OPENAI_COMPATIBLE_NPM,
505
616
  MODELS_FETCH_TIMEOUT_MS,
506
617
  ENV_BASE_URL,
618
+ ENV_API_URL,
507
619
  ENV_API_KEY,
508
620
  CONFIG_HOOK_REFRESH_BUDGET_MS
509
621
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcowger/opencode-plexus",
3
- "version": "0.8.4",
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"