@moxxy/plugin-provider-admin 0.26.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/LICENSE +21 -0
- package/dist/factory.d.ts +12 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +35 -0
- package/dist/factory.js.map +1 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +520 -0
- package/dist/index.js.map +1 -0
- package/dist/key-name.d.ts +27 -0
- package/dist/key-name.d.ts.map +1 -0
- package/dist/key-name.js +34 -0
- package/dist/key-name.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +108 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/configure.test.ts +157 -0
- package/src/discovery.test.ts +40 -0
- package/src/factory.test.ts +36 -0
- package/src/factory.ts +47 -0
- package/src/index.test.ts +662 -0
- package/src/index.ts +660 -0
- package/src/key-name.test.ts +61 -0
- package/src/key-name.ts +39 -0
- package/src/store.test.ts +115 -0
- package/src/store.ts +123 -0
- package/src/types.ts +38 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMutex,
|
|
3
|
+
defineTool,
|
|
4
|
+
definePlugin,
|
|
5
|
+
MoxxyError,
|
|
6
|
+
z,
|
|
7
|
+
type Mutex,
|
|
8
|
+
type Plugin,
|
|
9
|
+
type ProviderAdminView,
|
|
10
|
+
type ProviderConfigurePatch,
|
|
11
|
+
type ProviderDef,
|
|
12
|
+
} from '@moxxy/sdk';
|
|
13
|
+
import { buildProviderDef, validateOpenAICompatKey } from './factory.js';
|
|
14
|
+
import { providerApiKeyName } from './key-name.js';
|
|
15
|
+
import {
|
|
16
|
+
providersConfigPath,
|
|
17
|
+
readProvidersConfig,
|
|
18
|
+
removeStoredProvider,
|
|
19
|
+
upsertStoredProvider,
|
|
20
|
+
} from './store.js';
|
|
21
|
+
import type { StoredProvider } from './types.js';
|
|
22
|
+
|
|
23
|
+
/** Logger surface this plugin opportunistically uses if the host wires one. */
|
|
24
|
+
interface WarnLogger {
|
|
25
|
+
warn(msg: string, meta?: unknown): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `AppContext` doesn't declare a `logger` (warnings are best-effort), but some
|
|
30
|
+
* hosts attach one. Narrow with a runtime guard rather than asserting its shape
|
|
31
|
+
* via a blanket cast, so a non-conforming `logger` is simply ignored.
|
|
32
|
+
*/
|
|
33
|
+
function getWarnLogger(ctx: unknown): WarnLogger | undefined {
|
|
34
|
+
if (typeof ctx !== 'object' || ctx === null) return undefined;
|
|
35
|
+
const candidate = (ctx as { logger?: unknown }).logger;
|
|
36
|
+
if (typeof candidate === 'object' && candidate !== null && typeof (candidate as WarnLogger).warn === 'function') {
|
|
37
|
+
return candidate as WarnLogger;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { providersConfigPath, readProvidersConfig, upsertStoredProvider, removeStoredProvider };
|
|
43
|
+
export type { StoredProvider, StoredProviderOpenAICompat, StoredProvidersConfig } from './types.js';
|
|
44
|
+
export { buildProviderDef, validateOpenAICompatKey } from './factory.js';
|
|
45
|
+
export { providerApiKeyName, storedProviderApiKeyName } from './key-name.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimal subset of the in-process ProviderRegistry the admin plugin
|
|
49
|
+
* needs. Keeping the surface narrow lets us pass either the live
|
|
50
|
+
* `session.providers` from the CLI or a fake from tests.
|
|
51
|
+
*/
|
|
52
|
+
export interface ProviderRegistryLike {
|
|
53
|
+
register(def: ProviderDef): void;
|
|
54
|
+
replace(def: ProviderDef): void;
|
|
55
|
+
unregister(name: string): void;
|
|
56
|
+
list(): ReadonlyArray<ProviderDef>;
|
|
57
|
+
/**
|
|
58
|
+
* Name of the currently-active provider, when the registry tracks one. The
|
|
59
|
+
* live `session.providers` implements this; the narrow fake used in tests may
|
|
60
|
+
* not — hence optional. Used to detect when a `replace()`/`unregister()`
|
|
61
|
+
* targets the active provider so we can rebuild (or refuse).
|
|
62
|
+
*/
|
|
63
|
+
getActiveName?(): string | null;
|
|
64
|
+
/**
|
|
65
|
+
* Rebuild + activate a provider's instance. `replace()` drops the cached
|
|
66
|
+
* instance, so replacing the ACTIVE provider's def MUST be followed by
|
|
67
|
+
* `setActive(name, config)` or `getActive()` throws on the next turn (the
|
|
68
|
+
* invariant documented on core's ProviderRegistry).
|
|
69
|
+
*/
|
|
70
|
+
setActive?(name: string, config?: Record<string, unknown>): unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildProviderAdminPluginOptions {
|
|
74
|
+
/** Live provider registry — the plugin (un)registers stored defs against it. */
|
|
75
|
+
readonly providerRegistry: ProviderRegistryLike;
|
|
76
|
+
/** Override the on-disk path. Tests inject a tmp file here. */
|
|
77
|
+
readonly configPath?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Optional credential resolver. When supplied AND a reconfigure/replace
|
|
80
|
+
* targets the currently-active provider, the plugin rebuilds the active
|
|
81
|
+
* instance with this config so `getActive()` keeps working without a manual
|
|
82
|
+
* re-select. Without it the plugin leaves the active instance alone (the host
|
|
83
|
+
* — e.g. the runner, which owns the credential resolver — is responsible for
|
|
84
|
+
* the rebuild). Mirrors the runner's `setActive` config resolution.
|
|
85
|
+
*/
|
|
86
|
+
readonly resolveActiveConfig?: (name: string) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PROVIDER_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
90
|
+
|
|
91
|
+
const providerNameSchema = z
|
|
92
|
+
.string()
|
|
93
|
+
.min(1)
|
|
94
|
+
.max(60)
|
|
95
|
+
.regex(PROVIDER_NAME_RE, 'name must be slug-like (lowercase letters, digits, hyphens; must start with a letter)');
|
|
96
|
+
|
|
97
|
+
const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Defense-in-depth check on a vendor base URL before the OpenAI SDK probes it
|
|
101
|
+
* with a Bearer key. The permission prompt is the primary gate, but a base URL
|
|
102
|
+
* a user might not scrutinize should not be able to point a stored credential
|
|
103
|
+
* at an arbitrary host. We require https (allowing http only for explicit
|
|
104
|
+
* localhost) and reject link-local / cloud-metadata addresses (SSRF + key
|
|
105
|
+
* egress). The threat model treats baseURL as operator-controlled; this is a
|
|
106
|
+
* backstop, not the trust boundary.
|
|
107
|
+
*/
|
|
108
|
+
function isSafeBaseURL(value: string): boolean {
|
|
109
|
+
let url: URL;
|
|
110
|
+
try {
|
|
111
|
+
url = new URL(value);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const host = url.hostname.toLowerCase();
|
|
116
|
+
const isLocalhost = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
117
|
+
if (url.protocol === 'http:') return isLocalhost;
|
|
118
|
+
if (url.protocol !== 'https:') return false;
|
|
119
|
+
// `localhost` over https is the only allowed loopback name; everything below
|
|
120
|
+
// operates on the WHATWG-canonicalized host (decimal/hex/octal IPv4 forms are
|
|
121
|
+
// already folded to dotted-decimal, so a `169.254.x` / private prefix can't be
|
|
122
|
+
// smuggled as `https://2852039166/v1`).
|
|
123
|
+
// Block loopback, link-local / metadata, unspecified, AND RFC1918 private
|
|
124
|
+
// ranges even over https — a stored Bearer credential must not be egressable
|
|
125
|
+
// to an internal/metadata host via a base URL the user might not scrutinize.
|
|
126
|
+
if (
|
|
127
|
+
host === 'localhost' ||
|
|
128
|
+
host.startsWith('127.') || // IPv4 loopback 127.0.0.0/8
|
|
129
|
+
host.startsWith('169.254.') || // IPv4 link-local incl. cloud metadata 169.254.169.254
|
|
130
|
+
host.startsWith('0.') || // "this" network 0.0.0.0/8
|
|
131
|
+
host === '0.0.0.0' ||
|
|
132
|
+
host.startsWith('10.') || // RFC1918 10.0.0.0/8
|
|
133
|
+
host.startsWith('192.168.') || // RFC1918 192.168.0.0/16
|
|
134
|
+
isPrivate172(host) || // RFC1918 172.16.0.0/12
|
|
135
|
+
host === '::1' ||
|
|
136
|
+
host === '[::1]' || // IPv6 loopback
|
|
137
|
+
host.startsWith('[fe80:') || // IPv6 link-local
|
|
138
|
+
host.startsWith('[fc') || // IPv6 unique-local fc00::/7
|
|
139
|
+
host.startsWith('[fd')
|
|
140
|
+
) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True for a dotted-decimal IPv4 host in the RFC1918 172.16.0.0/12 range. */
|
|
147
|
+
function isPrivate172(host: string): boolean {
|
|
148
|
+
const m = /^172\.(\d{1,3})\./.exec(host);
|
|
149
|
+
if (!m) return false;
|
|
150
|
+
const octet = Number(m[1]);
|
|
151
|
+
return octet >= 16 && octet <= 31;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const safeBaseURLSchema = z
|
|
155
|
+
.string()
|
|
156
|
+
.url()
|
|
157
|
+
.refine(isSafeBaseURL, {
|
|
158
|
+
message:
|
|
159
|
+
'baseURL must be an https URL (or http://localhost). file://, ftp://, link-local and ' +
|
|
160
|
+
'cloud-metadata addresses are rejected to prevent credential egress / SSRF.',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const modelDescriptorSchema = z.object({
|
|
164
|
+
id: z.string().min(1),
|
|
165
|
+
contextWindow: z.number().int().positive(),
|
|
166
|
+
maxOutputTokens: z.number().int().positive().optional(),
|
|
167
|
+
supportsTools: z.boolean().default(true),
|
|
168
|
+
supportsStreaming: z.boolean().default(true),
|
|
169
|
+
supportsImages: z.boolean().optional(),
|
|
170
|
+
/**
|
|
171
|
+
* Whether the model ingests `document` blocks (native PDF etc.). Without
|
|
172
|
+
* this flag the desktop degrades attachments to extracted text for every
|
|
173
|
+
* runtime-registered provider — declare it for models that take files.
|
|
174
|
+
*/
|
|
175
|
+
supportsDocuments: z.boolean().optional(),
|
|
176
|
+
supportsAudio: z.boolean().optional(),
|
|
177
|
+
}).passthrough();
|
|
178
|
+
|
|
179
|
+
const addProviderInput = z.object({
|
|
180
|
+
kind: z
|
|
181
|
+
.enum(['openai-compat'])
|
|
182
|
+
.default('openai-compat')
|
|
183
|
+
.describe(
|
|
184
|
+
'Wire-protocol family the vendor speaks. "openai-compat" reuses the moxxy ' +
|
|
185
|
+
'OpenAI client against a vendor baseURL (z.ai, deepseek, groq, openrouter, …). ' +
|
|
186
|
+
'Native-SDK vendors must ship as a dedicated plugin instead.',
|
|
187
|
+
),
|
|
188
|
+
name: providerNameSchema.describe('Provider slug. Becomes the registry key + canonical vault entry (<NAME>_API_KEY).'),
|
|
189
|
+
baseURL: safeBaseURLSchema.describe('Vendor API base URL, e.g. https://api.z.ai/api/coding/paas/v4.'),
|
|
190
|
+
defaultModel: z.string().min(1).describe('Model id used when a request does not pin one explicitly.'),
|
|
191
|
+
models: z
|
|
192
|
+
.array(modelDescriptorSchema)
|
|
193
|
+
.min(1)
|
|
194
|
+
.describe('Models the vendor exposes. Powers /model autocomplete and the setup wizard.'),
|
|
195
|
+
envVar: z
|
|
196
|
+
.string()
|
|
197
|
+
.regex(ENV_VAR_RE)
|
|
198
|
+
.optional()
|
|
199
|
+
.describe('Override the API-key env-var name (defaults to <NAME>_API_KEY).'),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const removeProviderInput = z.object({
|
|
203
|
+
name: providerNameSchema,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Validation for the `configure()` patch. `configure` is part of the exported
|
|
208
|
+
* ProviderAdminView contract — ANY in-process consumer (a future channel, a
|
|
209
|
+
* test, a plugin) can call it, not just the runner handler. The invariant must
|
|
210
|
+
* live where the data is persisted, so a malformed baseURL/envVar can't reach
|
|
211
|
+
* buildProviderDef + the vault key-name derivation through an alternate caller.
|
|
212
|
+
* Mirrors the runner's providerConfigureParamsSchema.patch.
|
|
213
|
+
*/
|
|
214
|
+
const configurePatchSchema = z.object({
|
|
215
|
+
baseURL: safeBaseURLSchema.optional(),
|
|
216
|
+
defaultModel: z.string().min(1).optional(),
|
|
217
|
+
envVar: z.string().regex(ENV_VAR_RE).optional(),
|
|
218
|
+
models: z.array(modelDescriptorSchema).min(1).optional(),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const testProviderInput = z.object({
|
|
222
|
+
baseURL: safeBaseURLSchema.describe('Vendor API base URL to probe, e.g. https://api.deepseek.com.'),
|
|
223
|
+
keyName: z
|
|
224
|
+
.string()
|
|
225
|
+
.regex(ENV_VAR_RE)
|
|
226
|
+
.describe(
|
|
227
|
+
'NAME of the vault secret holding the API key (e.g. DEEPSEEK_API_KEY). The key is ' +
|
|
228
|
+
'resolved from the vault inside the tool — never ask the user for the plaintext key ' +
|
|
229
|
+
'and never pass one as a tool argument. Have them store it first: /vault set <NAME> <key>.',
|
|
230
|
+
),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Shared state + critical-section helpers for one wired plugin instance. Both
|
|
235
|
+
* {@link buildProviderAdminPlugin} and {@link buildProviderAdminPluginWithApi}
|
|
236
|
+
* drive the SAME engine so the built-in guard, active-provider rebuild and the
|
|
237
|
+
* registry-mutation lock are consistent across the tools and the configure API.
|
|
238
|
+
*/
|
|
239
|
+
interface ProviderAdminEngine {
|
|
240
|
+
readonly providerRegistry: ProviderRegistryLike;
|
|
241
|
+
readonly configPath?: string;
|
|
242
|
+
/** Names registered BY THIS PLUGIN (provider_add/configure/onInit). */
|
|
243
|
+
readonly ownNames: Set<string>;
|
|
244
|
+
/**
|
|
245
|
+
* Is `name` a host built-in (code provider) we must never replace? Evaluated
|
|
246
|
+
* LAZILY against the LIVE registry minus our own names — NOT snapshotted at
|
|
247
|
+
* build time, because the CLI builds this plugin BEFORE the host registers
|
|
248
|
+
* its built-in provider defs, so a build-time snapshot is empty and the guard
|
|
249
|
+
* is dead (a `provider_add({name:'openai'})` would silently hot-swap the real
|
|
250
|
+
* built-in's def). A name present in the live registry that we did not put
|
|
251
|
+
* there is, by definition, a built-in.
|
|
252
|
+
*/
|
|
253
|
+
isBuiltin(name: string): boolean;
|
|
254
|
+
/** Serialize the capture→registry-mutate→persist→rollback section per name. */
|
|
255
|
+
withLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
256
|
+
/**
|
|
257
|
+
* After `replace()` on the active provider, the cached instance is dropped
|
|
258
|
+
* (core ProviderRegistry invariant) so `getActive()` throws until rebuilt.
|
|
259
|
+
* Rebuild it with resolved config when the host wired a resolver. No-op when
|
|
260
|
+
* `name` isn't active or the registry doesn't track an active provider.
|
|
261
|
+
*/
|
|
262
|
+
rebuildActiveIfNeeded(name: string): Promise<void>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function createProviderAdminEngine(opts: BuildProviderAdminPluginOptions): ProviderAdminEngine {
|
|
266
|
+
const { providerRegistry, configPath, resolveActiveConfig } = opts;
|
|
267
|
+
const ownNames = new Set<string>();
|
|
268
|
+
// Per-name mutexes, ref-counted so the map is bounded by the number of
|
|
269
|
+
// CONCURRENTLY-active names, not the cumulative count of every slug ever seen.
|
|
270
|
+
// Without the counter a model issuing many distinct provider_add/remove calls
|
|
271
|
+
// would grow this map without bound for the life of the runner. We can't probe
|
|
272
|
+
// a Mutex's queue (the interface only exposes `run`), so we track holders
|
|
273
|
+
// ourselves and drop the entry when the last in-flight call for a name settles.
|
|
274
|
+
const locks = new Map<string, { mutex: Mutex; holders: number }>();
|
|
275
|
+
return {
|
|
276
|
+
providerRegistry,
|
|
277
|
+
configPath,
|
|
278
|
+
ownNames,
|
|
279
|
+
isBuiltin(name: string): boolean {
|
|
280
|
+
return providerRegistry.list().some((p) => p.name === name) && !ownNames.has(name);
|
|
281
|
+
},
|
|
282
|
+
async withLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
283
|
+
let slot = locks.get(name);
|
|
284
|
+
if (!slot) {
|
|
285
|
+
slot = { mutex: createMutex(), holders: 0 };
|
|
286
|
+
locks.set(name, slot);
|
|
287
|
+
}
|
|
288
|
+
slot.holders += 1;
|
|
289
|
+
try {
|
|
290
|
+
return await slot.mutex.run(fn);
|
|
291
|
+
} finally {
|
|
292
|
+
slot.holders -= 1;
|
|
293
|
+
// Last holder out removes the entry. A name acquired again later just
|
|
294
|
+
// creates a fresh mutex — serialization is only ever needed between
|
|
295
|
+
// OVERLAPPING calls, which by definition still share this live slot.
|
|
296
|
+
if (slot.holders === 0 && locks.get(name) === slot) locks.delete(name);
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
async rebuildActiveIfNeeded(name: string): Promise<void> {
|
|
300
|
+
if (!resolveActiveConfig) return;
|
|
301
|
+
const activeName = providerRegistry.getActiveName?.();
|
|
302
|
+
if (activeName !== name) return;
|
|
303
|
+
try {
|
|
304
|
+
const cfg = await resolveActiveConfig(name);
|
|
305
|
+
providerRegistry.setActive?.(name, cfg);
|
|
306
|
+
} catch {
|
|
307
|
+
// Best-effort: a failed rebuild leaves `getActive()` throwing exactly as
|
|
308
|
+
// it did before this fix — never worse — and the user can re-select.
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Re-register a stored provider against the live registry + persist it. The
|
|
316
|
+
* whole capture→mutate→persist→rollback sequence runs under the per-name lock so
|
|
317
|
+
* concurrent admin calls (parallel tool calls / a configure racing an add) can't
|
|
318
|
+
* interleave with stale prevDef snapshots. On a write failure the prior def is
|
|
319
|
+
* restored (or the phantom dropped) so the registry never drifts from disk.
|
|
320
|
+
*/
|
|
321
|
+
async function applyStoredProvider(engine: ProviderAdminEngine, entry: StoredProvider): Promise<{ replaced: boolean }> {
|
|
322
|
+
const { providerRegistry, configPath } = engine;
|
|
323
|
+
return engine.withLock(entry.name, async () => {
|
|
324
|
+
const def = buildProviderDef(entry);
|
|
325
|
+
const prevDef = providerRegistry.list().find((p) => p.name === entry.name);
|
|
326
|
+
const wasRegistered = prevDef !== undefined;
|
|
327
|
+
if (wasRegistered) providerRegistry.replace(def);
|
|
328
|
+
else providerRegistry.register(def);
|
|
329
|
+
engine.ownNames.add(entry.name);
|
|
330
|
+
try {
|
|
331
|
+
await upsertStoredProvider(entry, configPath);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
// Roll back the runtime registration. If a def existed before this call,
|
|
334
|
+
// restore it; otherwise drop the phantom + our ownership claim so the next
|
|
335
|
+
// boot doesn't see something that isn't on disk.
|
|
336
|
+
if (wasRegistered && prevDef) providerRegistry.replace(prevDef);
|
|
337
|
+
else {
|
|
338
|
+
providerRegistry.unregister(entry.name);
|
|
339
|
+
engine.ownNames.delete(entry.name);
|
|
340
|
+
}
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
// Replacing the active provider's def dropped its cached instance — rebuild.
|
|
344
|
+
if (wasRegistered) await engine.rebuildActiveIfNeeded(entry.name);
|
|
345
|
+
return { replaced: wasRegistered };
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Like {@link buildProviderAdminPlugin} but also returns a
|
|
351
|
+
* {@link ProviderAdminView} api the host can stash on the session
|
|
352
|
+
* (`session.providerAdmin`) so channels — and the runner's
|
|
353
|
+
* `provider.configure` method — can edit a stored provider without going
|
|
354
|
+
* through the model. Mirrors `buildMcpAdminPluginWithApi`.
|
|
355
|
+
*/
|
|
356
|
+
export function buildProviderAdminPluginWithApi(opts: BuildProviderAdminPluginOptions): {
|
|
357
|
+
readonly plugin: Plugin;
|
|
358
|
+
readonly api: ProviderAdminView;
|
|
359
|
+
} {
|
|
360
|
+
const engine = createProviderAdminEngine(opts);
|
|
361
|
+
const api: ProviderAdminView = {
|
|
362
|
+
configure: async (name: string, patch: ProviderConfigurePatch): Promise<void> => {
|
|
363
|
+
if (engine.isBuiltin(name)) {
|
|
364
|
+
throw new MoxxyError({
|
|
365
|
+
code: 'CONFIG_INVALID',
|
|
366
|
+
message:
|
|
367
|
+
`provider-admin: "${name}" is a built-in provider and cannot be reconfigured here — ` +
|
|
368
|
+
`built-ins are code. Only runtime-registered (providers.json) providers are editable.`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
// Defense-in-depth: validate the patch HERE (where it is persisted), not
|
|
372
|
+
// only at the runner boundary, so any in-process caller is covered.
|
|
373
|
+
const validated = configurePatchSchema.parse(patch);
|
|
374
|
+
const cfg = await readProvidersConfig(engine.configPath);
|
|
375
|
+
const entry = cfg.providers.find((p) => p.name === name);
|
|
376
|
+
if (!entry) {
|
|
377
|
+
throw new MoxxyError({
|
|
378
|
+
code: 'CONFIG_INVALID',
|
|
379
|
+
message:
|
|
380
|
+
`provider-admin: no stored provider named "${name}" — only runtime-registered ` +
|
|
381
|
+
`(providers.json) providers are configurable; built-ins are code.`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const next: StoredProvider = {
|
|
385
|
+
...entry,
|
|
386
|
+
...(validated.baseURL ? { baseURL: validated.baseURL } : {}),
|
|
387
|
+
...(validated.defaultModel ? { defaultModel: validated.defaultModel } : {}),
|
|
388
|
+
...(validated.envVar ? { envVar: validated.envVar } : {}),
|
|
389
|
+
...(validated.models && validated.models.length > 0 ? { models: validated.models } : {}),
|
|
390
|
+
};
|
|
391
|
+
if (!next.models.some((m) => m.id === next.defaultModel)) {
|
|
392
|
+
throw new MoxxyError({
|
|
393
|
+
code: 'CONFIG_INVALID',
|
|
394
|
+
message:
|
|
395
|
+
`provider-admin: defaultModel "${next.defaultModel}" is not in the models list ` +
|
|
396
|
+
`(${next.models.map((m) => m.id).join(', ')}).`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// Same order as provider_add: live registry first, then disk, so a failed
|
|
400
|
+
// write rolls back; the whole section is locked per name.
|
|
401
|
+
await applyStoredProvider(engine, next);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
return { plugin: buildProviderAdminPlugin(opts, engine), api };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function buildProviderAdminPlugin(
|
|
408
|
+
opts: BuildProviderAdminPluginOptions,
|
|
409
|
+
sharedEngine?: ProviderAdminEngine,
|
|
410
|
+
): Plugin {
|
|
411
|
+
const engine = sharedEngine ?? createProviderAdminEngine(opts);
|
|
412
|
+
const { providerRegistry, configPath } = engine;
|
|
413
|
+
|
|
414
|
+
return definePlugin({
|
|
415
|
+
name: '@moxxy/plugin-provider-admin',
|
|
416
|
+
version: '0.0.0',
|
|
417
|
+
tools: [
|
|
418
|
+
defineTool({
|
|
419
|
+
name: 'provider_add',
|
|
420
|
+
description:
|
|
421
|
+
'Register an OpenAI-compatible LLM provider (z.ai, deepseek, groq, openrouter, fireworks, ' +
|
|
422
|
+
'together, mistral, …) with moxxy. Wraps the in-process OpenAI client with the vendor baseURL + ' +
|
|
423
|
+
'a user-supplied models list. Persists to ~/.moxxy/providers.json so the provider survives ' +
|
|
424
|
+
'restarts. The new provider is registered in the LIVE session — switch to it with /provider ' +
|
|
425
|
+
'or set it as the default in moxxy.config.ts.',
|
|
426
|
+
inputSchema: addProviderInput,
|
|
427
|
+
permission: { action: 'prompt' },
|
|
428
|
+
handler: async (input) => {
|
|
429
|
+
if (engine.isBuiltin(input.name)) {
|
|
430
|
+
throw new MoxxyError({
|
|
431
|
+
code: 'CONFIG_INVALID',
|
|
432
|
+
message:
|
|
433
|
+
`provider_add: "${input.name}" is a built-in provider and cannot be shadowed ` +
|
|
434
|
+
`or redirected. Pick a different slug for your OpenAI-compatible vendor ` +
|
|
435
|
+
`(e.g. "${input.name}-compat").`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
const entry: StoredProvider = {
|
|
439
|
+
kind: 'openai-compat',
|
|
440
|
+
name: input.name,
|
|
441
|
+
baseURL: input.baseURL,
|
|
442
|
+
defaultModel: input.defaultModel,
|
|
443
|
+
models: input.models,
|
|
444
|
+
...(input.envVar ? { envVar: input.envVar } : {}),
|
|
445
|
+
createdAt: new Date().toISOString(),
|
|
446
|
+
};
|
|
447
|
+
if (!entry.models.some((m) => m.id === entry.defaultModel)) {
|
|
448
|
+
throw new MoxxyError({
|
|
449
|
+
code: 'CONFIG_INVALID',
|
|
450
|
+
message:
|
|
451
|
+
`provider_add: defaultModel "${entry.defaultModel}" is not in the models list. ` +
|
|
452
|
+
`Add it to the array or pick one of: ${entry.models.map((m) => m.id).join(', ')}.`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const { replaced: wasRegistered } = await applyStoredProvider(engine, entry);
|
|
456
|
+
const vaultKeyName = providerApiKeyName(entry);
|
|
457
|
+
return {
|
|
458
|
+
ok: true,
|
|
459
|
+
name: entry.name,
|
|
460
|
+
kind: entry.kind,
|
|
461
|
+
baseURL: entry.baseURL,
|
|
462
|
+
defaultModel: entry.defaultModel,
|
|
463
|
+
models: entry.models.map((m) => m.id),
|
|
464
|
+
path: configPath ?? providersConfigPath(),
|
|
465
|
+
replaced: wasRegistered,
|
|
466
|
+
note:
|
|
467
|
+
`Provider "${entry.name}" is live in this session. ` +
|
|
468
|
+
`Have the USER store the API key by running: /vault set ${vaultKeyName} <key> ` +
|
|
469
|
+
`— never ask them to paste the key to you. ` +
|
|
470
|
+
`Once stored, you can verify it with provider_test (baseURL + keyName "${vaultKeyName}") — it resolves the key from the vault itself. ` +
|
|
471
|
+
`Switch with the /provider command or set provider.name in moxxy.config.ts.`,
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
defineTool({
|
|
476
|
+
name: 'provider_list',
|
|
477
|
+
description:
|
|
478
|
+
'List user-registered providers (persisted in ~/.moxxy/providers.json) plus their default model and base URL. ' +
|
|
479
|
+
'Built-in providers (anthropic, openai, openai-codex) are NOT included — query session.providers for those.',
|
|
480
|
+
inputSchema: z.object({}),
|
|
481
|
+
handler: async () => {
|
|
482
|
+
const cfg = await readProvidersConfig(configPath);
|
|
483
|
+
return {
|
|
484
|
+
path: configPath ?? providersConfigPath(),
|
|
485
|
+
providers: cfg.providers.map((p) => ({
|
|
486
|
+
name: p.name,
|
|
487
|
+
kind: p.kind,
|
|
488
|
+
baseURL: p.baseURL,
|
|
489
|
+
defaultModel: p.defaultModel,
|
|
490
|
+
models: p.models.map((m) => m.id),
|
|
491
|
+
envVar: providerApiKeyName(p),
|
|
492
|
+
})),
|
|
493
|
+
};
|
|
494
|
+
},
|
|
495
|
+
}),
|
|
496
|
+
defineTool({
|
|
497
|
+
name: 'provider_remove',
|
|
498
|
+
description:
|
|
499
|
+
'Remove a previously-added provider from ~/.moxxy/providers.json and detach it from the live session. ' +
|
|
500
|
+
'Does NOT delete the stored API key — call vault_delete name=<NAME>_API_KEY separately if you also want to drop the credential.',
|
|
501
|
+
inputSchema: removeProviderInput,
|
|
502
|
+
permission: { action: 'prompt' },
|
|
503
|
+
handler: async ({ name }) => {
|
|
504
|
+
// Removing the ACTIVE provider leaves the registry with active=null, so
|
|
505
|
+
// the next turn fails with a "no active provider" error. Mirror the
|
|
506
|
+
// setEnabled guard's intent: surface the consequence prominently
|
|
507
|
+
// instead of returning a cheerful ok with no warning.
|
|
508
|
+
const removingActive = providerRegistry.getActiveName?.() === name;
|
|
509
|
+
return engine.withLock(name, async () => {
|
|
510
|
+
const removed = await removeStoredProvider(name, configPath);
|
|
511
|
+
if (!removed) {
|
|
512
|
+
return { ok: false, name, note: `No stored provider named "${name}".` };
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
providerRegistry.unregister(name);
|
|
516
|
+
} catch {
|
|
517
|
+
// Already gone in the live registry — best effort.
|
|
518
|
+
}
|
|
519
|
+
engine.ownNames.delete(name);
|
|
520
|
+
const note = removingActive
|
|
521
|
+
? `Removed "${name}" from providers.json and detached from session. ` +
|
|
522
|
+
`WARNING: "${name}" was the ACTIVE provider — the session now has NO active provider ` +
|
|
523
|
+
`and the next turn will fail until you switch with the /provider command.`
|
|
524
|
+
: `Removed "${name}" from providers.json and detached from session.`;
|
|
525
|
+
return { ok: true, name, removedActive: removingActive, note };
|
|
526
|
+
});
|
|
527
|
+
},
|
|
528
|
+
}),
|
|
529
|
+
defineTool({
|
|
530
|
+
name: 'provider_test',
|
|
531
|
+
description:
|
|
532
|
+
'Probe an OpenAI-compatible endpoint by calling /v1/models. Takes the NAME of a vault ' +
|
|
533
|
+
'secret (e.g. ZAI_API_KEY) and resolves the API key via the vault inside the handler — ' +
|
|
534
|
+
'the plaintext key never enters the conversation or logs. Have the USER store the key ' +
|
|
535
|
+
'first with /vault set <NAME> <key>, then call this to confirm the baseURL + key are ' +
|
|
536
|
+
'valid (typically before provider_add). Returns { ok: true } on success or ' +
|
|
537
|
+
'{ ok: false, message } with the vendor error verbatim.',
|
|
538
|
+
inputSchema: testProviderInput,
|
|
539
|
+
permission: { action: 'prompt' },
|
|
540
|
+
// The plaintext key is resolved HERE, at call time, via ctx.getSecret —
|
|
541
|
+
// it never appears as tool input/output, so it stays out of the model
|
|
542
|
+
// context, the runner session log, and the desktop NDJSON log.
|
|
543
|
+
handler: async ({ baseURL, keyName }, ctx) => {
|
|
544
|
+
if (!ctx.getSecret) {
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
message:
|
|
548
|
+
'provider_test: this session has no secret vault wired in (ctx.getSecret is ' +
|
|
549
|
+
'unavailable), so the key cannot be resolved. Register the provider with ' +
|
|
550
|
+
'provider_add and have the user verify the key with `moxxy doctor` instead.',
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const apiKey = await ctx.getSecret(keyName);
|
|
554
|
+
if (!apiKey) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
message:
|
|
558
|
+
`provider_test: no vault secret named "${keyName}". Ask the USER to store it ` +
|
|
559
|
+
`by running: /vault set ${keyName} <key> — then call provider_test again with ` +
|
|
560
|
+
`keyName "${keyName}". Never ask them to paste the key into the conversation.`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return validateOpenAICompatKey(apiKey, { baseURL });
|
|
564
|
+
},
|
|
565
|
+
}),
|
|
566
|
+
],
|
|
567
|
+
hooks: {
|
|
568
|
+
onInit: async (ctx) => {
|
|
569
|
+
const log = getWarnLogger(ctx);
|
|
570
|
+
let cfg;
|
|
571
|
+
try {
|
|
572
|
+
cfg = await readProvidersConfig(configPath);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
log?.warn('provider-admin: failed to read providers.json', {
|
|
575
|
+
err: err instanceof Error ? err.message : String(err),
|
|
576
|
+
});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
for (const entry of cfg.providers) {
|
|
580
|
+
try {
|
|
581
|
+
// Evaluate built-in collision against the live registry MINUS our
|
|
582
|
+
// own names BEFORE claiming ownership. A name present in the registry
|
|
583
|
+
// that we didn't put there is a built-in (code provider) and must
|
|
584
|
+
// never be overwritten by a poisoned/legacy store entry.
|
|
585
|
+
if (engine.isBuiltin(entry.name)) {
|
|
586
|
+
log?.warn(
|
|
587
|
+
`provider-admin: skipping stored provider "${entry.name}" — it collides with a built-in`,
|
|
588
|
+
);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const def = buildProviderDef(entry);
|
|
592
|
+
const already = providerRegistry.list().some((p) => p.name === entry.name);
|
|
593
|
+
if (already) providerRegistry.replace(def);
|
|
594
|
+
else providerRegistry.register(def);
|
|
595
|
+
engine.ownNames.add(entry.name);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
log?.warn(`provider-admin: failed to register "${entry.name}"`, {
|
|
598
|
+
err: err instanceof Error ? err.message : String(err),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Discovery-loadable default export. Resolves the provider registry (`'providers'`)
|
|
609
|
+
* and the credential accessor (`'resolveCredentials'`) from the inter-plugin
|
|
610
|
+
* service registry in `onInit`, then publishes its admin api as the
|
|
611
|
+
* `'providerAdmin'` service (which core's `Session.providerAdmin` getter exposes
|
|
612
|
+
* to the runner + desktop) — replacing the host stash + `{ providerRegistry,
|
|
613
|
+
* resolveActiveConfig }` closure. A lazy `Proxy` defers every registry call to
|
|
614
|
+
* the resolved instance, so the tools + the stored-provider re-registration run
|
|
615
|
+
* unchanged once `onInit` has wired it.
|
|
616
|
+
*/
|
|
617
|
+
type CredResolver = (name: string) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
618
|
+
|
|
619
|
+
export const providerAdminPlugin: Plugin = (() => {
|
|
620
|
+
let resolvedRegistry: ProviderRegistryLike | null = null;
|
|
621
|
+
let resolveCreds: CredResolver | null = null;
|
|
622
|
+
|
|
623
|
+
const lazyRegistry = new Proxy({} as ProviderRegistryLike, {
|
|
624
|
+
get(_target, prop) {
|
|
625
|
+
if (!resolvedRegistry) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
'@moxxy/plugin-provider-admin: the "providers" service is unavailable — the host must publish it',
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const value = (resolvedRegistry as unknown as Record<string | symbol, unknown>)[prop];
|
|
631
|
+
return typeof value === 'function'
|
|
632
|
+
? (value as (...args: unknown[]) => unknown).bind(resolvedRegistry)
|
|
633
|
+
: value;
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const { plugin, api } = buildProviderAdminPluginWithApi({
|
|
638
|
+
providerRegistry: lazyRegistry,
|
|
639
|
+
resolveActiveConfig: (name) => (resolveCreds ? resolveCreds(name) : {}),
|
|
640
|
+
});
|
|
641
|
+
const innerOnInit = plugin.hooks?.onInit;
|
|
642
|
+
|
|
643
|
+
return definePlugin({
|
|
644
|
+
...plugin,
|
|
645
|
+
hooks: {
|
|
646
|
+
...plugin.hooks,
|
|
647
|
+
onInit: async (ctx) => {
|
|
648
|
+
resolvedRegistry = ctx.services.get<ProviderRegistryLike>('providers') ?? null;
|
|
649
|
+
resolveCreds = ctx.services.get<CredResolver>('resolveCredentials') ?? null;
|
|
650
|
+
ctx.services.register('providerAdmin', api);
|
|
651
|
+
// Now that the registry is resolved, run the stored-provider
|
|
652
|
+
// re-registration (it drives the lazy registry).
|
|
653
|
+
await innerOnInit?.(ctx);
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
})();
|
|
658
|
+
|
|
659
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
660
|
+
export default providerAdminPlugin;
|