@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.
- package/README.md +43 -8
- package/dist/index.js +214 -102
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
128
|
+
const next = rootURL(baseURL);
|
|
125
129
|
if (!next)
|
|
126
130
|
return "";
|
|
127
|
-
return
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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.
|
|
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.
|
|
30
|
-
"@opencode-ai/sdk": "^1.
|
|
29
|
+
"@opencode-ai/plugin": "^1.17.18",
|
|
30
|
+
"@opencode-ai/sdk": "^1.17.18"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"typescript": "^5"
|