@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.
- package/README.md +43 -8
- package/dist/index.js +220 -128
- 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) {
|
|
@@ -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
|
-
|
|
89
|
-
|
|
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(
|
|
90
|
+
async function readCachedModels(_client) {
|
|
104
91
|
try {
|
|
105
|
-
const dir =
|
|
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(
|
|
103
|
+
async function writeCache(_client, models, raw) {
|
|
117
104
|
try {
|
|
118
|
-
const dir =
|
|
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 =
|
|
128
|
+
const next = rootURL(baseURL);
|
|
145
129
|
if (!next)
|
|
146
130
|
return "";
|
|
147
|
-
return
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
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;
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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.
|
|
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"
|