@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/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;