@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
|
@@ -0,0 +1,61 @@
|
|
|
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 { providerApiKeyName, storedProviderApiKeyName } from './key-name.js';
|
|
6
|
+
import { upsertStoredProvider } from './store.js';
|
|
7
|
+
import type { StoredProvider } from './types.js';
|
|
8
|
+
|
|
9
|
+
describe('providerApiKeyName', () => {
|
|
10
|
+
it('upper-snakes the slug and appends _API_KEY', () => {
|
|
11
|
+
expect(providerApiKeyName('deepseek')).toBe('DEEPSEEK_API_KEY');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('maps hyphens to underscores so the result is a valid env-var name', () => {
|
|
15
|
+
// The CLI's old derivation kept the hyphen (`Z-AI_API_KEY`), which is
|
|
16
|
+
// not a valid POSIX env-var name and diverged from the desktop's.
|
|
17
|
+
expect(providerApiKeyName('z-ai')).toBe('Z_AI_API_KEY');
|
|
18
|
+
expect(providerApiKeyName({ name: 'z-ai' })).toBe('Z_AI_API_KEY');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('honors a stored envVar override over the derived name', () => {
|
|
22
|
+
expect(providerApiKeyName({ name: 'zai', envVar: 'ZHIPU_KEY' })).toBe('ZHIPU_KEY');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('storedProviderApiKeyName', () => {
|
|
27
|
+
let tmpDir: string;
|
|
28
|
+
let cfgPath: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-key-name-'));
|
|
32
|
+
cfgPath = path.join(tmpDir, 'providers.json');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const entry = (over: Partial<StoredProvider> = {}): StoredProvider => ({
|
|
40
|
+
kind: 'openai-compat',
|
|
41
|
+
name: 'my-vendor',
|
|
42
|
+
baseURL: 'https://api.example.com/v1',
|
|
43
|
+
defaultModel: 'm1',
|
|
44
|
+
models: [{ id: 'm1', contextWindow: 100_000, supportsTools: true, supportsStreaming: true }],
|
|
45
|
+
...over,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns the envVar override for a stored provider', async () => {
|
|
49
|
+
await upsertStoredProvider(entry({ envVar: 'CUSTOM_KEY' }), cfgPath);
|
|
50
|
+
await expect(storedProviderApiKeyName('my-vendor', cfgPath)).resolves.toBe('CUSTOM_KEY');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('falls back to the canonical derivation when no override is stored', async () => {
|
|
54
|
+
await upsertStoredProvider(entry(), cfgPath);
|
|
55
|
+
await expect(storedProviderApiKeyName('my-vendor', cfgPath)).resolves.toBe('MY_VENDOR_API_KEY');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null for providers not in providers.json (built-ins)', async () => {
|
|
59
|
+
await expect(storedProviderApiKeyName('anthropic', cfgPath)).resolves.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/key-name.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readProvidersConfig } from './store.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE canonical derivation of the vault/env key name that holds a
|
|
5
|
+
* provider's API key. Every consumer (provider-admin tools, the CLI's
|
|
6
|
+
* credential resolution, the desktop's provider discovery) must agree on
|
|
7
|
+
* this name or keys stored by one surface become invisible to another —
|
|
8
|
+
* previously the CLI, the admin tools and the desktop each derived it
|
|
9
|
+
* slightly differently (the CLI didn't map `-` → `_` and ignored the
|
|
10
|
+
* stored `envVar` override).
|
|
11
|
+
*
|
|
12
|
+
* Rules:
|
|
13
|
+
* - a stored `envVar` override always wins;
|
|
14
|
+
* - otherwise the provider slug is upper-snaked and suffixed with
|
|
15
|
+
* `_API_KEY` (`z-ai` → `Z_AI_API_KEY`) — hyphens become underscores
|
|
16
|
+
* so the result is a valid POSIX env-var name.
|
|
17
|
+
*/
|
|
18
|
+
export function providerApiKeyName(
|
|
19
|
+
provider: string | { readonly name: string; readonly envVar?: string },
|
|
20
|
+
): string {
|
|
21
|
+
if (typeof provider !== 'string' && provider.envVar) return provider.envVar;
|
|
22
|
+
const name = typeof provider === 'string' ? provider : provider.name;
|
|
23
|
+
return `${name.toUpperCase().replace(/-/g, '_')}_API_KEY`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Key name for a provider honoring any `envVar` override persisted in
|
|
28
|
+
* ~/.moxxy/providers.json. Returns `null` when the provider isn't a
|
|
29
|
+
* stored (runtime-registered) one — callers fall back to
|
|
30
|
+
* `providerApiKeyName(name)` for built-ins.
|
|
31
|
+
*/
|
|
32
|
+
export async function storedProviderApiKeyName(
|
|
33
|
+
providerName: string,
|
|
34
|
+
configPath?: string,
|
|
35
|
+
): Promise<string | null> {
|
|
36
|
+
const cfg = await readProvidersConfig(configPath);
|
|
37
|
+
const entry = cfg.providers.find((p) => p.name === providerName);
|
|
38
|
+
return entry ? providerApiKeyName(entry) : null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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 {
|
|
6
|
+
readProvidersConfig,
|
|
7
|
+
removeStoredProvider,
|
|
8
|
+
upsertStoredProvider,
|
|
9
|
+
writeProvidersConfig,
|
|
10
|
+
} from './store.js';
|
|
11
|
+
import type { StoredProvider } from './types.js';
|
|
12
|
+
|
|
13
|
+
const sampleEntry: StoredProvider = {
|
|
14
|
+
kind: 'openai-compat',
|
|
15
|
+
name: 'zai',
|
|
16
|
+
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
17
|
+
defaultModel: 'glm-4.6',
|
|
18
|
+
models: [{ id: 'glm-4.6', contextWindow: 200_000, supportsTools: true, supportsStreaming: true }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let tmpDir: string;
|
|
22
|
+
let cfgPath: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-provider-admin-'));
|
|
26
|
+
cfgPath = path.join(tmpDir, 'providers.json');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('providers.json store', () => {
|
|
34
|
+
it('returns an empty list when the file is missing', async () => {
|
|
35
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
36
|
+
expect(cfg.providers).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('round-trips a single provider through write/read', async () => {
|
|
40
|
+
await writeProvidersConfig({ providers: [sampleEntry] }, cfgPath);
|
|
41
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
42
|
+
expect(cfg.providers).toHaveLength(1);
|
|
43
|
+
expect(cfg.providers[0]).toMatchObject({ name: 'zai', defaultModel: 'glm-4.6' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('upsert replaces an entry with the same name (no duplicates)', async () => {
|
|
47
|
+
await upsertStoredProvider(sampleEntry, cfgPath);
|
|
48
|
+
const updated: StoredProvider = { ...sampleEntry, defaultModel: 'glm-4.5-air' };
|
|
49
|
+
const next = await upsertStoredProvider(updated, cfgPath);
|
|
50
|
+
expect(next.providers).toHaveLength(1);
|
|
51
|
+
expect(next.providers[0]!.defaultModel).toBe('glm-4.5-air');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('upsert appends distinct entries', async () => {
|
|
55
|
+
await upsertStoredProvider(sampleEntry, cfgPath);
|
|
56
|
+
const second: StoredProvider = { ...sampleEntry, name: 'deepseek', baseURL: 'https://api.deepseek.com' };
|
|
57
|
+
const next = await upsertStoredProvider(second, cfgPath);
|
|
58
|
+
expect(next.providers).toHaveLength(2);
|
|
59
|
+
expect(next.providers.map((p) => p.name).sort()).toEqual(['deepseek', 'zai']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('remove returns false when the entry was not present', async () => {
|
|
63
|
+
expect(await removeStoredProvider('nonexistent', cfgPath)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('remove drops the entry and returns true', async () => {
|
|
67
|
+
await upsertStoredProvider(sampleEntry, cfgPath);
|
|
68
|
+
expect(await removeStoredProvider('zai', cfgPath)).toBe(true);
|
|
69
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
70
|
+
expect(cfg.providers).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('treats malformed JSON as empty', async () => {
|
|
74
|
+
await fs.writeFile(cfgPath, '{ not json', 'utf8');
|
|
75
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
76
|
+
expect(cfg.providers).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rethrows a genuine IO error instead of collapsing it to an empty catalog', async () => {
|
|
80
|
+
// A read failure that is NOT "file missing" (here: the path resolves to a
|
|
81
|
+
// DIRECTORY → EISDIR) must surface as a real error, not silently become an
|
|
82
|
+
// empty catalog the next write would then CLOBBER.
|
|
83
|
+
const dirAsFile = path.join(tmpDir, 'a-directory');
|
|
84
|
+
await fs.mkdir(dirAsFile);
|
|
85
|
+
await expect(readProvidersConfig(dirAsFile)).rejects.toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('defaults supportsTools/supportsStreaming on a legacy on-disk model', async () => {
|
|
89
|
+
// A hand-edited / legacy entry with only {id, contextWindow} must round-trip
|
|
90
|
+
// into a complete ModelDescriptor (required booleans defaulted true), not
|
|
91
|
+
// reach buildProviderDef with `undefined` flags.
|
|
92
|
+
await fs.writeFile(
|
|
93
|
+
cfgPath,
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
providers: [
|
|
96
|
+
{
|
|
97
|
+
kind: 'openai-compat',
|
|
98
|
+
name: 'legacy',
|
|
99
|
+
baseURL: 'https://api.legacy.com/v1',
|
|
100
|
+
defaultModel: 'm',
|
|
101
|
+
models: [{ id: 'm', contextWindow: 1000 }],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
105
|
+
'utf8',
|
|
106
|
+
);
|
|
107
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
108
|
+
expect(cfg.providers[0]!.models[0]).toMatchObject({
|
|
109
|
+
id: 'm',
|
|
110
|
+
contextWindow: 1000,
|
|
111
|
+
supportsTools: true,
|
|
112
|
+
supportsStreaming: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { createMutex, z } from '@moxxy/sdk';
|
|
3
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
4
|
+
import type { StoredProvider, StoredProvidersConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* User-level provider catalog. Mirrors the MCP admin storage pattern:
|
|
8
|
+
* a JSON file in ~/.moxxy/ that the admin tools mutate and the plugin's
|
|
9
|
+
* onInit hook reads back on every boot to repopulate the registry.
|
|
10
|
+
*/
|
|
11
|
+
export function providersConfigPath(): string {
|
|
12
|
+
return moxxyPath('providers.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Schema for the on-disk providers.json. Kept loose on the model
|
|
17
|
+
* descriptor (passthrough) so a richer descriptor written by a newer
|
|
18
|
+
* build round-trips through an older one without losing fields, but
|
|
19
|
+
* strict enough to discard a structurally-bogus file.
|
|
20
|
+
*/
|
|
21
|
+
const storedModelSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
id: z.string().min(1),
|
|
24
|
+
contextWindow: z.number(),
|
|
25
|
+
// ModelDescriptor declares these as REQUIRED booleans; default them here so
|
|
26
|
+
// a hand-edited / legacy providers.json round-trips into a complete
|
|
27
|
+
// descriptor instead of reaching buildProviderDef with `undefined` (which
|
|
28
|
+
// channels treat differently from `false` for tool/streaming gating).
|
|
29
|
+
supportsTools: z.boolean().default(true),
|
|
30
|
+
supportsStreaming: z.boolean().default(true),
|
|
31
|
+
})
|
|
32
|
+
.passthrough();
|
|
33
|
+
|
|
34
|
+
const storedProviderSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
kind: z.literal('openai-compat'),
|
|
37
|
+
name: z.string().min(1),
|
|
38
|
+
baseURL: z.string().min(1),
|
|
39
|
+
defaultModel: z.string().min(1),
|
|
40
|
+
models: z.array(storedModelSchema),
|
|
41
|
+
envVar: z.string().optional(),
|
|
42
|
+
createdAt: z.string().optional(),
|
|
43
|
+
})
|
|
44
|
+
.passthrough();
|
|
45
|
+
|
|
46
|
+
const storedProvidersConfigSchema = z.object({
|
|
47
|
+
providers: z.array(storedProviderSchema),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export async function readProvidersConfig(filePath: string = providersConfigPath()): Promise<StoredProvidersConfig> {
|
|
51
|
+
let raw: string;
|
|
52
|
+
try {
|
|
53
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// A MISSING file means "no providers yet" — start fresh. But a genuine IO /
|
|
56
|
+
// permission error (EACCES, EBUSY, EMFILE, …) must NOT be collapsed into an
|
|
57
|
+
// empty catalog: doing so makes the plugin behave as if zero providers are
|
|
58
|
+
// registered and the next read-modify-write would CLOBBER the real file.
|
|
59
|
+
// Rethrow so callers (onInit's try/catch, the admin tools) see the failure.
|
|
60
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { providers: [] };
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
// Malformed JSON / structurally-bogus content is intentionally treated as an
|
|
64
|
+
// empty catalog (start fresh) rather than a hard failure.
|
|
65
|
+
const parsed = storedProvidersConfigSchema.safeParse(parseJsonSafe(raw));
|
|
66
|
+
if (parsed.success) {
|
|
67
|
+
// The schema is intentionally looser than StoredProvidersConfig (model
|
|
68
|
+
// descriptors are validated as id+contextWindow + passthrough, not the
|
|
69
|
+
// full ModelDescriptor) so newer/older builds round-trip without losing
|
|
70
|
+
// fields. Assert the domain type after the structural check.
|
|
71
|
+
return parsed.data as unknown as StoredProvidersConfig;
|
|
72
|
+
}
|
|
73
|
+
return { providers: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseJsonSafe(raw: string): unknown {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw) as unknown;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function writeProvidersConfig(
|
|
85
|
+
cfg: StoredProvidersConfig,
|
|
86
|
+
filePath: string = providersConfigPath(),
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
await writeFileAtomic(filePath, JSON.stringify(cfg, null, 2) + '\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Serializes the read-modify-write mutators below. Without this, two
|
|
93
|
+
* concurrent upsert/remove calls could both read the same baseline and
|
|
94
|
+
* the second write would clobber the first.
|
|
95
|
+
*/
|
|
96
|
+
const writeMutex = createMutex();
|
|
97
|
+
|
|
98
|
+
export async function upsertStoredProvider(
|
|
99
|
+
entry: StoredProvider,
|
|
100
|
+
filePath: string = providersConfigPath(),
|
|
101
|
+
): Promise<StoredProvidersConfig> {
|
|
102
|
+
return writeMutex.run(async () => {
|
|
103
|
+
const cfg = await readProvidersConfig(filePath);
|
|
104
|
+
const next = cfg.providers.filter((p) => p.name !== entry.name);
|
|
105
|
+
next.push(entry);
|
|
106
|
+
const updated: StoredProvidersConfig = { providers: next };
|
|
107
|
+
await writeProvidersConfig(updated, filePath);
|
|
108
|
+
return updated;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function removeStoredProvider(
|
|
113
|
+
name: string,
|
|
114
|
+
filePath: string = providersConfigPath(),
|
|
115
|
+
): Promise<boolean> {
|
|
116
|
+
return writeMutex.run(async () => {
|
|
117
|
+
const cfg = await readProvidersConfig(filePath);
|
|
118
|
+
const next = cfg.providers.filter((p) => p.name !== name);
|
|
119
|
+
if (next.length === cfg.providers.length) return false;
|
|
120
|
+
await writeProvidersConfig({ providers: next }, filePath);
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ModelDescriptor } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persisted entry for a user-registered LLM provider. Lives in
|
|
5
|
+
* ~/.moxxy/providers.json and is re-read on every boot to re-register
|
|
6
|
+
* the provider against the in-process registry.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 only supports `openai-compat` — i.e. vendors that speak the
|
|
9
|
+
* OpenAI Chat Completions wire protocol (z.ai, deepseek, groq,
|
|
10
|
+
* openrouter, fireworks, together, mistral, …). The host wraps the
|
|
11
|
+
* shared `@moxxy/plugin-provider-openai` client with the vendor's
|
|
12
|
+
* `baseURL`, so we don't reimplement streaming for each new vendor.
|
|
13
|
+
*
|
|
14
|
+
* Future kinds (e.g. `anthropic-compat`, native vendor SDKs) extend the
|
|
15
|
+
* discriminated union; the rest of the pipeline (store + onInit) is
|
|
16
|
+
* agnostic so they slot in without touching the persistence layer.
|
|
17
|
+
*/
|
|
18
|
+
export interface StoredProviderOpenAICompat {
|
|
19
|
+
readonly kind: 'openai-compat';
|
|
20
|
+
/** Provider name (slug). Becomes the registry key + canonical vault entry stem. */
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/** Vendor base URL, e.g. `https://api.z.ai/api/coding/paas/v4`. */
|
|
23
|
+
readonly baseURL: string;
|
|
24
|
+
/** Model id used when the request didn't pin one explicitly. */
|
|
25
|
+
readonly defaultModel: string;
|
|
26
|
+
/** Models the vendor exposes. Powers `/model` autocomplete + the setup wizard. */
|
|
27
|
+
readonly models: ReadonlyArray<ModelDescriptor>;
|
|
28
|
+
/** Optional vendor-supplied env var name for the API key (defaults to `<NAME>_API_KEY`). */
|
|
29
|
+
readonly envVar?: string;
|
|
30
|
+
/** ISO timestamp the entry was written. Informational only. */
|
|
31
|
+
readonly createdAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type StoredProvider = StoredProviderOpenAICompat;
|
|
35
|
+
|
|
36
|
+
export interface StoredProvidersConfig {
|
|
37
|
+
readonly providers: ReadonlyArray<StoredProvider>;
|
|
38
|
+
}
|