@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/dist/store.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { createMutex, z } from '@moxxy/sdk';
|
|
3
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
4
|
+
/**
|
|
5
|
+
* User-level provider catalog. Mirrors the MCP admin storage pattern:
|
|
6
|
+
* a JSON file in ~/.moxxy/ that the admin tools mutate and the plugin's
|
|
7
|
+
* onInit hook reads back on every boot to repopulate the registry.
|
|
8
|
+
*/
|
|
9
|
+
export function providersConfigPath() {
|
|
10
|
+
return moxxyPath('providers.json');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Schema for the on-disk providers.json. Kept loose on the model
|
|
14
|
+
* descriptor (passthrough) so a richer descriptor written by a newer
|
|
15
|
+
* build round-trips through an older one without losing fields, but
|
|
16
|
+
* strict enough to discard a structurally-bogus file.
|
|
17
|
+
*/
|
|
18
|
+
const storedModelSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
id: z.string().min(1),
|
|
21
|
+
contextWindow: z.number(),
|
|
22
|
+
// ModelDescriptor declares these as REQUIRED booleans; default them here so
|
|
23
|
+
// a hand-edited / legacy providers.json round-trips into a complete
|
|
24
|
+
// descriptor instead of reaching buildProviderDef with `undefined` (which
|
|
25
|
+
// channels treat differently from `false` for tool/streaming gating).
|
|
26
|
+
supportsTools: z.boolean().default(true),
|
|
27
|
+
supportsStreaming: z.boolean().default(true),
|
|
28
|
+
})
|
|
29
|
+
.passthrough();
|
|
30
|
+
const storedProviderSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
kind: z.literal('openai-compat'),
|
|
33
|
+
name: z.string().min(1),
|
|
34
|
+
baseURL: z.string().min(1),
|
|
35
|
+
defaultModel: z.string().min(1),
|
|
36
|
+
models: z.array(storedModelSchema),
|
|
37
|
+
envVar: z.string().optional(),
|
|
38
|
+
createdAt: z.string().optional(),
|
|
39
|
+
})
|
|
40
|
+
.passthrough();
|
|
41
|
+
const storedProvidersConfigSchema = z.object({
|
|
42
|
+
providers: z.array(storedProviderSchema),
|
|
43
|
+
});
|
|
44
|
+
export async function readProvidersConfig(filePath = providersConfigPath()) {
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// A MISSING file means "no providers yet" — start fresh. But a genuine IO /
|
|
51
|
+
// permission error (EACCES, EBUSY, EMFILE, …) must NOT be collapsed into an
|
|
52
|
+
// empty catalog: doing so makes the plugin behave as if zero providers are
|
|
53
|
+
// registered and the next read-modify-write would CLOBBER the real file.
|
|
54
|
+
// Rethrow so callers (onInit's try/catch, the admin tools) see the failure.
|
|
55
|
+
if (err?.code === 'ENOENT')
|
|
56
|
+
return { providers: [] };
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
// Malformed JSON / structurally-bogus content is intentionally treated as an
|
|
60
|
+
// empty catalog (start fresh) rather than a hard failure.
|
|
61
|
+
const parsed = storedProvidersConfigSchema.safeParse(parseJsonSafe(raw));
|
|
62
|
+
if (parsed.success) {
|
|
63
|
+
// The schema is intentionally looser than StoredProvidersConfig (model
|
|
64
|
+
// descriptors are validated as id+contextWindow + passthrough, not the
|
|
65
|
+
// full ModelDescriptor) so newer/older builds round-trip without losing
|
|
66
|
+
// fields. Assert the domain type after the structural check.
|
|
67
|
+
return parsed.data;
|
|
68
|
+
}
|
|
69
|
+
return { providers: [] };
|
|
70
|
+
}
|
|
71
|
+
function parseJsonSafe(raw) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function writeProvidersConfig(cfg, filePath = providersConfigPath()) {
|
|
80
|
+
await writeFileAtomic(filePath, JSON.stringify(cfg, null, 2) + '\n');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Serializes the read-modify-write mutators below. Without this, two
|
|
84
|
+
* concurrent upsert/remove calls could both read the same baseline and
|
|
85
|
+
* the second write would clobber the first.
|
|
86
|
+
*/
|
|
87
|
+
const writeMutex = createMutex();
|
|
88
|
+
export async function upsertStoredProvider(entry, filePath = providersConfigPath()) {
|
|
89
|
+
return writeMutex.run(async () => {
|
|
90
|
+
const cfg = await readProvidersConfig(filePath);
|
|
91
|
+
const next = cfg.providers.filter((p) => p.name !== entry.name);
|
|
92
|
+
next.push(entry);
|
|
93
|
+
const updated = { providers: next };
|
|
94
|
+
await writeProvidersConfig(updated, filePath);
|
|
95
|
+
return updated;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export async function removeStoredProvider(name, filePath = providersConfigPath()) {
|
|
99
|
+
return writeMutex.run(async () => {
|
|
100
|
+
const cfg = await readProvidersConfig(filePath);
|
|
101
|
+
const next = cfg.providers.filter((p) => p.name !== name);
|
|
102
|
+
if (next.length === cfg.providers.length)
|
|
103
|
+
return false;
|
|
104
|
+
await writeProvidersConfig({ providers: next }, filePath);
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ModelDescriptor } from '@moxxy/sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Persisted entry for a user-registered LLM provider. Lives in
|
|
4
|
+
* ~/.moxxy/providers.json and is re-read on every boot to re-register
|
|
5
|
+
* the provider against the in-process registry.
|
|
6
|
+
*
|
|
7
|
+
* Phase 1 only supports `openai-compat` — i.e. vendors that speak the
|
|
8
|
+
* OpenAI Chat Completions wire protocol (z.ai, deepseek, groq,
|
|
9
|
+
* openrouter, fireworks, together, mistral, …). The host wraps the
|
|
10
|
+
* shared `@moxxy/plugin-provider-openai` client with the vendor's
|
|
11
|
+
* `baseURL`, so we don't reimplement streaming for each new vendor.
|
|
12
|
+
*
|
|
13
|
+
* Future kinds (e.g. `anthropic-compat`, native vendor SDKs) extend the
|
|
14
|
+
* discriminated union; the rest of the pipeline (store + onInit) is
|
|
15
|
+
* agnostic so they slot in without touching the persistence layer.
|
|
16
|
+
*/
|
|
17
|
+
export interface StoredProviderOpenAICompat {
|
|
18
|
+
readonly kind: 'openai-compat';
|
|
19
|
+
/** Provider name (slug). Becomes the registry key + canonical vault entry stem. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Vendor base URL, e.g. `https://api.z.ai/api/coding/paas/v4`. */
|
|
22
|
+
readonly baseURL: string;
|
|
23
|
+
/** Model id used when the request didn't pin one explicitly. */
|
|
24
|
+
readonly defaultModel: string;
|
|
25
|
+
/** Models the vendor exposes. Powers `/model` autocomplete + the setup wizard. */
|
|
26
|
+
readonly models: ReadonlyArray<ModelDescriptor>;
|
|
27
|
+
/** Optional vendor-supplied env var name for the API key (defaults to `<NAME>_API_KEY`). */
|
|
28
|
+
readonly envVar?: string;
|
|
29
|
+
/** ISO timestamp the entry was written. Informational only. */
|
|
30
|
+
readonly createdAt?: string;
|
|
31
|
+
}
|
|
32
|
+
export type StoredProvider = StoredProviderOpenAICompat;
|
|
33
|
+
export interface StoredProvidersConfig {
|
|
34
|
+
readonly providers: ReadonlyArray<StoredProvider>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,gEAAgE;IAChE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAChD,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,MAAM,cAAc,GAAG,0BAA0B,CAAC;AAExD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;CACnD"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-provider-admin",
|
|
3
|
+
"version": "0.26.0",
|
|
4
|
+
"description": "Model-callable provider admin: provider_add / provider_list / provider_remove / provider_test. Persists user-registered providers to ~/.moxxy/providers.json and hot-registers them in the live session by wrapping the OpenAI-compatible client with a vendor baseURL.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"provider",
|
|
9
|
+
"admin",
|
|
10
|
+
"tools"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-provider-admin"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "tools"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"openai": "^4.80.0",
|
|
47
|
+
"zod": "^3.24.0",
|
|
48
|
+
"@moxxy/plugin-provider-openai": "0.26.0",
|
|
49
|
+
"@moxxy/sdk": "0.26.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^22.10.0",
|
|
53
|
+
"typescript": "^5.7.3",
|
|
54
|
+
"vitest": "^2.1.8",
|
|
55
|
+
"@moxxy/vitest-preset": "0.0.0",
|
|
56
|
+
"@moxxy/tsconfig": "0.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsc -p tsconfig.json",
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"clean": "rm -rf dist .turbo"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { MoxxyError, type ProviderDef } from '@moxxy/sdk';
|
|
6
|
+
import { buildProviderAdminPluginWithApi, type ProviderRegistryLike } from './index.js';
|
|
7
|
+
import { readProvidersConfig, upsertStoredProvider } from './store.js';
|
|
8
|
+
import type { StoredProvider } from './types.js';
|
|
9
|
+
|
|
10
|
+
class FakeRegistry implements ProviderRegistryLike {
|
|
11
|
+
defs = new Map<string, ProviderDef>();
|
|
12
|
+
register(def: ProviderDef): void {
|
|
13
|
+
if (this.defs.has(def.name)) throw new Error(`already registered: ${def.name}`);
|
|
14
|
+
this.defs.set(def.name, def);
|
|
15
|
+
}
|
|
16
|
+
replace(def: ProviderDef): void {
|
|
17
|
+
this.defs.set(def.name, def);
|
|
18
|
+
}
|
|
19
|
+
unregister(name: string): void {
|
|
20
|
+
this.defs.delete(name);
|
|
21
|
+
}
|
|
22
|
+
list(): ReadonlyArray<ProviderDef> {
|
|
23
|
+
return [...this.defs.values()];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let tmpDir: string;
|
|
28
|
+
let cfgPath: string;
|
|
29
|
+
let registry: FakeRegistry;
|
|
30
|
+
|
|
31
|
+
const zaiEntry: StoredProvider = {
|
|
32
|
+
kind: 'openai-compat',
|
|
33
|
+
name: 'zai',
|
|
34
|
+
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
35
|
+
defaultModel: 'glm-4.6',
|
|
36
|
+
models: [
|
|
37
|
+
{ id: 'glm-4.6', contextWindow: 200_000, supportsTools: true, supportsStreaming: true },
|
|
38
|
+
{ id: 'glm-4.5-air', contextWindow: 128_000, supportsTools: true, supportsStreaming: true },
|
|
39
|
+
],
|
|
40
|
+
createdAt: new Date('2026-01-01').toISOString(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-provider-admin-configure-'));
|
|
45
|
+
cfgPath = path.join(tmpDir, 'providers.json');
|
|
46
|
+
registry = new FakeRegistry();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function build(reg: ProviderRegistryLike = registry) {
|
|
54
|
+
return buildProviderAdminPluginWithApi({ providerRegistry: reg, configPath: cfgPath });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function seedZai(): Promise<void> {
|
|
58
|
+
await upsertStoredProvider(zaiEntry, cfgPath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('buildProviderAdminPluginWithApi.configure', () => {
|
|
62
|
+
it('throws CONFIG_INVALID when no stored provider has that name', async () => {
|
|
63
|
+
const { api } = build();
|
|
64
|
+
const err = await api.configure('nope', { defaultModel: 'x' }).catch((e) => e);
|
|
65
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
66
|
+
expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
|
|
67
|
+
expect((err as MoxxyError).message).toMatch(/no stored provider named "nope"/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses to reconfigure a built-in provider (reserved name)', async () => {
|
|
71
|
+
const reg = new FakeRegistry();
|
|
72
|
+
reg.register({ name: 'openai', models: [{ id: 'gpt-x', contextWindow: 1 }] } as unknown as ProviderDef);
|
|
73
|
+
const { api } = build(reg);
|
|
74
|
+
const err = await api.configure('openai', { defaultModel: 'gpt-x' }).catch((e) => e);
|
|
75
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
76
|
+
expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
|
|
77
|
+
expect((err as MoxxyError).message).toMatch(/built-in/i);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('merges only the provided patch fields, leaving the rest intact', async () => {
|
|
81
|
+
await seedZai();
|
|
82
|
+
const { api } = build();
|
|
83
|
+
await api.configure('zai', { baseURL: 'https://new.example.com/v1' });
|
|
84
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
85
|
+
const stored = cfg.providers.find((p) => p.name === 'zai')!;
|
|
86
|
+
expect(stored.baseURL).toBe('https://new.example.com/v1');
|
|
87
|
+
// Untouched fields preserved.
|
|
88
|
+
expect(stored.defaultModel).toBe('glm-4.6');
|
|
89
|
+
expect(stored.models.map((m) => m.id)).toEqual(['glm-4.6', 'glm-4.5-air']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('throws CONFIG_INVALID when the merged defaultModel is not in the models list', async () => {
|
|
93
|
+
await seedZai();
|
|
94
|
+
const { api } = build();
|
|
95
|
+
const err = await api.configure('zai', { defaultModel: 'not-a-model' }).catch((e) => e);
|
|
96
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
97
|
+
expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
|
|
98
|
+
expect((err as MoxxyError).message).toMatch(/not in the models list/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('lets defaultModel point at a model introduced by the same patch', async () => {
|
|
102
|
+
await seedZai();
|
|
103
|
+
const { api } = build();
|
|
104
|
+
await api.configure('zai', {
|
|
105
|
+
defaultModel: 'glm-4.7',
|
|
106
|
+
models: [{ id: 'glm-4.7', contextWindow: 256_000, supportsTools: true, supportsStreaming: true }],
|
|
107
|
+
});
|
|
108
|
+
const stored = (await readProvidersConfig(cfgPath)).providers.find((p) => p.name === 'zai')!;
|
|
109
|
+
expect(stored.defaultModel).toBe('glm-4.7');
|
|
110
|
+
expect(stored.models.map((m) => m.id)).toEqual(['glm-4.7']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('updates both the live registry def and the on-disk store on success', async () => {
|
|
114
|
+
await seedZai();
|
|
115
|
+
// Mirror the runtime: the entry is also live in the registry (onInit would
|
|
116
|
+
// have registered it). configure() must replace() it, not register().
|
|
117
|
+
const { plugin, api } = build();
|
|
118
|
+
await plugin.hooks!.onInit!({} as never);
|
|
119
|
+
expect(registry.defs.has('zai')).toBe(true);
|
|
120
|
+
|
|
121
|
+
await api.configure('zai', { defaultModel: 'glm-4.5-air' });
|
|
122
|
+
const def = registry.defs.get('zai')!;
|
|
123
|
+
expect(def.models.find((m) => m.id === 'glm-4.5-air')).toBeTruthy();
|
|
124
|
+
const stored = (await readProvidersConfig(cfgPath)).providers.find((p) => p.name === 'zai')!;
|
|
125
|
+
expect(stored.defaultModel).toBe('glm-4.5-air');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('rolls back the live def to the prior registration when the disk write fails', async () => {
|
|
129
|
+
// The entry is readable on disk AND already live in the registry (owned by
|
|
130
|
+
// the plugin via onInit), but the write target is made unwritable so
|
|
131
|
+
// upsertStoredProvider rejects after the live replace() — driving the
|
|
132
|
+
// rollback branch (restore the prior owned def).
|
|
133
|
+
await seedZai();
|
|
134
|
+
const reg = new FakeRegistry();
|
|
135
|
+
const { plugin, api } = build(reg);
|
|
136
|
+
// onInit registers + CLAIMS OWNERSHIP of 'zai' (so configure treats it as a
|
|
137
|
+
// runtime-registered provider, not a reserved built-in).
|
|
138
|
+
await plugin.hooks!.onInit!({} as never);
|
|
139
|
+
const priorDef = reg.defs.get('zai')!;
|
|
140
|
+
expect(priorDef).toBeTruthy();
|
|
141
|
+
|
|
142
|
+
// Read still works (file present), but the dir is read-only so the atomic
|
|
143
|
+
// write's temp-file create / rename fails.
|
|
144
|
+
await fs.chmod(tmpDir, 0o500);
|
|
145
|
+
try {
|
|
146
|
+
const err = await api.configure('zai', { defaultModel: 'glm-4.5-air' }).catch((e) => e);
|
|
147
|
+
expect(err).toBeTruthy();
|
|
148
|
+
// The prior def must be restored EXACTLY (not the patched one, not deleted).
|
|
149
|
+
expect(reg.defs.get('zai')).toBe(priorDef);
|
|
150
|
+
} finally {
|
|
151
|
+
await fs.chmod(tmpDir, 0o700);
|
|
152
|
+
}
|
|
153
|
+
// Disk is unchanged: still the seeded defaultModel.
|
|
154
|
+
const stored = (await readProvidersConfig(cfgPath)).providers.find((p) => p.name === 'zai')!;
|
|
155
|
+
expect(stored.defaultModel).toBe('glm-4.6');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ServiceRegistry } from '@moxxy/sdk';
|
|
3
|
+
import { providerAdminPlugin } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The discovery-loadable default export resolves the providers registry +
|
|
7
|
+
* credential accessor from the service registry in onInit and publishes its
|
|
8
|
+
* admin api as the 'providerAdmin' service (which Session.providerAdmin exposes),
|
|
9
|
+
* instead of the host `{ providerRegistry, resolveActiveConfig }` closure + stash.
|
|
10
|
+
*/
|
|
11
|
+
describe('providerAdminPlugin (discovery-loadable)', () => {
|
|
12
|
+
it('exposes the provider tools + an onInit hook', () => {
|
|
13
|
+
const names = providerAdminPlugin.tools?.map((t) => t.name) ?? [];
|
|
14
|
+
expect(names).toContain('provider_add');
|
|
15
|
+
expect(typeof providerAdminPlugin.hooks?.onInit).toBe('function');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('onInit resolves providers + resolveCredentials and publishes providerAdmin', async () => {
|
|
19
|
+
const registry = {
|
|
20
|
+
list: () => [],
|
|
21
|
+
register: () => {},
|
|
22
|
+
replace: () => {},
|
|
23
|
+
unregister: () => {},
|
|
24
|
+
getActiveName: () => null,
|
|
25
|
+
};
|
|
26
|
+
const get = vi.fn((name: string) =>
|
|
27
|
+
name === 'providers' ? registry : name === 'resolveCredentials' ? () => ({}) : undefined,
|
|
28
|
+
);
|
|
29
|
+
const register = vi.fn();
|
|
30
|
+
const services = { get, register, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
|
|
31
|
+
|
|
32
|
+
// The inner onInit reads ~/.moxxy/providers.json (absent on CI → caught +
|
|
33
|
+
// skipped); we only assert the service wiring here.
|
|
34
|
+
await providerAdminPlugin.hooks!.onInit!({ services } as never);
|
|
35
|
+
|
|
36
|
+
expect(get).toHaveBeenCalledWith('providers');
|
|
37
|
+
expect(get).toHaveBeenCalledWith('resolveCredentials');
|
|
38
|
+
expect(register).toHaveBeenCalledWith('providerAdmin', expect.anything());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildProviderDef } from './factory.js';
|
|
3
|
+
import type { StoredProvider } from './types.js';
|
|
4
|
+
|
|
5
|
+
describe('buildProviderDef', () => {
|
|
6
|
+
it('builds an openai-compat ProviderDef wired to the vendor baseURL', () => {
|
|
7
|
+
const entry: StoredProvider = {
|
|
8
|
+
kind: 'openai-compat',
|
|
9
|
+
name: 'zai',
|
|
10
|
+
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
11
|
+
defaultModel: 'glm-4.6',
|
|
12
|
+
models: [
|
|
13
|
+
{ id: 'glm-4.6', contextWindow: 200_000, supportsTools: true, supportsStreaming: true },
|
|
14
|
+
{ id: 'glm-4.5-air', contextWindow: 128_000, supportsTools: true, supportsStreaming: true },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
const def = buildProviderDef(entry);
|
|
18
|
+
expect(def.name).toBe('zai');
|
|
19
|
+
expect(def.models.map((m) => m.id)).toEqual(['glm-4.6', 'glm-4.5-air']);
|
|
20
|
+
// createClient must produce something with .stream/.countTokens — the
|
|
21
|
+
// OpenAIProvider satisfies LLMProvider, we just check the shape.
|
|
22
|
+
const client = def.createClient({ apiKey: 'test-key' });
|
|
23
|
+
expect(typeof client.stream).toBe('function');
|
|
24
|
+
expect(typeof client.countTokens).toBe('function');
|
|
25
|
+
// The client reports the VENDOR's slug + catalog, not 'openai' — usage
|
|
26
|
+
// stats, provider_request/response events and error context all read
|
|
27
|
+
// these off the live client, so 'openai' here misattributed every
|
|
28
|
+
// runtime vendor and missed context-window lookups for their models.
|
|
29
|
+
expect(client.name).toBe('zai');
|
|
30
|
+
expect(client.models.map((m) => m.id)).toEqual(['glm-4.6', 'glm-4.5-air']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws for unknown kinds', () => {
|
|
34
|
+
expect(() => buildProviderDef({ kind: 'mystery' } as unknown as StoredProvider)).toThrow(/unsupported kind/);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { MoxxyError, type ProviderDef } from '@moxxy/sdk';
|
|
2
|
+
import { defineOpenAICompatProvider, validateOpenAICompatKey } from '@moxxy/plugin-provider-openai';
|
|
3
|
+
import type { StoredProvider } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-export the shared OpenAI-compatible key validator so existing
|
|
7
|
+
* consumers (and `index.ts`) keep a single import surface. The probe
|
|
8
|
+
* lives in `@moxxy/plugin-provider-openai` — we don't keep a local copy.
|
|
9
|
+
*/
|
|
10
|
+
export { validateOpenAICompatKey };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builder for one stored-provider `kind`. New wire-protocol families
|
|
14
|
+
* (anthropic-compat, native SDKs) register a builder here instead of growing
|
|
15
|
+
* an if/throw ladder — the rest of the pipeline (store + onInit) stays
|
|
16
|
+
* kind-agnostic, honoring the forward-compat promise in `types.ts`.
|
|
17
|
+
*/
|
|
18
|
+
type ProviderDefBuilder = (entry: StoredProvider) => ProviderDef;
|
|
19
|
+
|
|
20
|
+
const PROVIDER_DEF_BUILDERS: Record<StoredProvider['kind'], ProviderDefBuilder> = {
|
|
21
|
+
// For `openai-compat` we delegate to the shared {@link defineOpenAICompatProvider}
|
|
22
|
+
// factory that the built-in vendor plugins (xai/zai/google/local) also use: it
|
|
23
|
+
// forces the vendor's slug + baseURL + default model + catalog onto the shared
|
|
24
|
+
// OpenAI client and wires validateKey against the same baseURL, so the
|
|
25
|
+
// setup-wizard / `moxxy doctor --check-keys` paths work end-to-end. The runtime
|
|
26
|
+
// config only carries the resolved API key, so the factory's narrow pick keeps
|
|
27
|
+
// just that and the vendor's stored baseURL/defaultModel win.
|
|
28
|
+
'openai-compat': (entry) =>
|
|
29
|
+
defineOpenAICompatProvider({
|
|
30
|
+
name: entry.name,
|
|
31
|
+
baseURL: entry.baseURL,
|
|
32
|
+
defaultModel: entry.defaultModel,
|
|
33
|
+
models: entry.models,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Build a runtime ProviderDef from a stored entry by dispatching on `kind`. */
|
|
38
|
+
export function buildProviderDef(entry: StoredProvider): ProviderDef {
|
|
39
|
+
const build = PROVIDER_DEF_BUILDERS[entry.kind];
|
|
40
|
+
if (!build) {
|
|
41
|
+
throw new MoxxyError({
|
|
42
|
+
code: 'CONFIG_INVALID',
|
|
43
|
+
message: `provider-admin: unsupported kind ${String((entry as { kind: unknown }).kind)}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return build(entry);
|
|
47
|
+
}
|