@maheidem/model-discovery 0.7.1 → 0.8.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/README.md +49 -12
- package/application.ts +104 -0
- package/commands.ts +70 -0
- package/index.ts +475 -452
- package/package.json +28 -5
- package/storage.ts +265 -0
- package/ui/wizard-shell.ts +442 -0
- package/ui-model.ts +127 -0
- package/scripts/bisect-grammar.ts +0 -65
- package/scripts/live-schema-repair-check.ts +0 -86
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maheidem/model-discovery",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Interactive
|
|
5
|
+
"description": "Interactive Pi wizard for discovering local AI endpoints and defining named thinking/sampling profiles.",
|
|
6
|
+
"license": "MIT",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"pi-package",
|
|
8
9
|
"extension",
|
|
@@ -18,17 +19,39 @@
|
|
|
18
19
|
"bugs": {
|
|
19
20
|
"url": "https://github.com/maheidem/model-discovery/issues"
|
|
20
21
|
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.ts",
|
|
24
|
+
"application.ts",
|
|
25
|
+
"commands.ts",
|
|
26
|
+
"storage.ts",
|
|
27
|
+
"ui-model.ts",
|
|
28
|
+
"profiles.ts",
|
|
29
|
+
"providers.ts",
|
|
30
|
+
"schema-repair.ts",
|
|
31
|
+
"ui/wizard-shell.ts",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
21
35
|
"scripts": {
|
|
22
|
-
"test": "node
|
|
36
|
+
"test": "node tests/run.mjs",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
38
|
+
"prepack": "npm run typecheck && npm test"
|
|
23
39
|
},
|
|
24
40
|
"peerDependencies": {
|
|
25
|
-
"@earendil-works/pi-coding-agent": "
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
26
42
|
"@earendil-works/pi-tui": "*",
|
|
27
43
|
"typebox": "*"
|
|
28
44
|
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
47
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"typebox": "^1.0.17",
|
|
50
|
+
"typescript": "^5.9.3"
|
|
51
|
+
},
|
|
29
52
|
"pi": {
|
|
30
53
|
"extensions": [
|
|
31
|
-
"index.ts"
|
|
54
|
+
"./index.ts"
|
|
32
55
|
]
|
|
33
56
|
}
|
|
34
57
|
}
|
package/storage.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
migrateLegacyProfileRouting,
|
|
6
|
+
validateModelProfile,
|
|
7
|
+
type ModelProfile,
|
|
8
|
+
type ModelProfileRouting,
|
|
9
|
+
} from "./profiles.ts";
|
|
10
|
+
import { redactSecret } from "./providers.ts";
|
|
11
|
+
|
|
12
|
+
export interface ModelOverride {
|
|
13
|
+
contextWindow?: number;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
reasoning?: boolean;
|
|
16
|
+
input?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscoveredProvider {
|
|
20
|
+
name: string;
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
serverType?: string;
|
|
24
|
+
defaultContextWindow?: number;
|
|
25
|
+
defaultMaxTokens?: number;
|
|
26
|
+
modelOverrides?: Record<string, ModelOverride>;
|
|
27
|
+
modelProfiles?: Record<string, ModelProfile[]>;
|
|
28
|
+
modelProfileRouting?: Record<string, ModelProfileRouting>;
|
|
29
|
+
profileSchemaVersion?: number;
|
|
30
|
+
cachedModels?: Record<string, unknown>[];
|
|
31
|
+
compat?: Record<string, unknown>;
|
|
32
|
+
/**
|
|
33
|
+
* Inline $defs/$ref in outgoing tool schemas for this endpoint (default: true for
|
|
34
|
+
* local/self-hosted endpoints, where llama.cpp-style grammar converters reject any
|
|
35
|
+
* $ref that is not resolvable at the document root). Set false to send verbatim.
|
|
36
|
+
*/
|
|
37
|
+
repairToolSchemas?: boolean;
|
|
38
|
+
/** Last successful live catalogue refresh (legacy name retained in storage). */
|
|
39
|
+
lastScanned?: number;
|
|
40
|
+
lastScanAttempt?: number;
|
|
41
|
+
lastScanError?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StorageDiagnostic {
|
|
45
|
+
kind: "warning";
|
|
46
|
+
message: string;
|
|
47
|
+
preservedPath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const STORAGE_PATH = join(os.homedir(), ".pi", "agent", "model-discovery.json");
|
|
51
|
+
let latestStorageDiagnostic: StorageDiagnostic | undefined;
|
|
52
|
+
|
|
53
|
+
export function getStorageDiagnostic(): StorageDiagnostic | undefined {
|
|
54
|
+
return latestStorageDiagnostic;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function errorMessage(error: unknown): string {
|
|
58
|
+
return error instanceof Error ? error.message : String(error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function corruptBackupPath(): string {
|
|
62
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
63
|
+
const base = `${STORAGE_PATH}.corrupt-${stamp}`;
|
|
64
|
+
let candidate = base;
|
|
65
|
+
let suffix = 1;
|
|
66
|
+
while (existsSync(candidate)) candidate = `${base}-${suffix++}`;
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function preserveCorruptStorage(error: unknown): void {
|
|
71
|
+
const backupPath = corruptBackupPath();
|
|
72
|
+
try {
|
|
73
|
+
renameSync(STORAGE_PATH, backupPath);
|
|
74
|
+
latestStorageDiagnostic = {
|
|
75
|
+
kind: "warning",
|
|
76
|
+
message: `Invalid configuration was preserved as ${backupPath}.`,
|
|
77
|
+
preservedPath: backupPath,
|
|
78
|
+
};
|
|
79
|
+
} catch (backupError) {
|
|
80
|
+
latestStorageDiagnostic = {
|
|
81
|
+
kind: "warning",
|
|
82
|
+
message: `Configuration could not be read (${errorMessage(error)}) or preserved (${errorMessage(backupError)}).`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeProvidersAtomic(providers: DiscoveredProvider[]): void {
|
|
88
|
+
mkdirSync(dirname(STORAGE_PATH), { recursive: true, mode: 0o700 });
|
|
89
|
+
const tempPath = `${STORAGE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(tempPath, JSON.stringify(providers, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
92
|
+
renameSync(tempPath, STORAGE_PATH);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
try {
|
|
95
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort cleanup */
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadProviders(): DiscoveredProvider[] {
|
|
104
|
+
if (!existsSync(STORAGE_PATH)) return [];
|
|
105
|
+
let raw: string;
|
|
106
|
+
try {
|
|
107
|
+
raw = readFileSync(STORAGE_PATH, "utf-8");
|
|
108
|
+
} catch (error) {
|
|
109
|
+
latestStorageDiagnostic = {
|
|
110
|
+
kind: "warning",
|
|
111
|
+
message: `Configuration could not be read: ${errorMessage(error)}`,
|
|
112
|
+
};
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let parsed: unknown;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(raw);
|
|
119
|
+
if (!Array.isArray(parsed)) throw new Error("Expected the top-level value to be an array of providers.");
|
|
120
|
+
if (!parsed.every((provider) =>
|
|
121
|
+
provider !== null &&
|
|
122
|
+
typeof provider === "object" &&
|
|
123
|
+
typeof (provider as { name?: unknown }).name === "string" &&
|
|
124
|
+
typeof (provider as { baseUrl?: unknown }).baseUrl === "string"
|
|
125
|
+
)) {
|
|
126
|
+
throw new Error("Every provider requires string name and baseUrl fields.");
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
preserveCorruptStorage(error);
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const providers = parsed as DiscoveredProvider[];
|
|
134
|
+
let migrated = false;
|
|
135
|
+
for (const provider of providers) {
|
|
136
|
+
if (!provider || typeof provider !== "object") continue;
|
|
137
|
+
if ((provider.profileSchemaVersion ?? 0) >= 2) continue;
|
|
138
|
+
for (const [modelId, rawProfiles] of Object.entries(provider.modelProfiles ?? {})) {
|
|
139
|
+
if (!Array.isArray(rawProfiles)) continue;
|
|
140
|
+
const result = migrateLegacyProfileRouting(rawProfiles, provider.modelProfileRouting?.[modelId]);
|
|
141
|
+
if (!result.changed || !result.routing) continue;
|
|
142
|
+
provider.modelProfiles = { ...provider.modelProfiles, [modelId]: result.profiles };
|
|
143
|
+
provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: result.routing };
|
|
144
|
+
migrated = true;
|
|
145
|
+
}
|
|
146
|
+
provider.profileSchemaVersion = 2;
|
|
147
|
+
migrated = true;
|
|
148
|
+
}
|
|
149
|
+
if (migrated) writeProvidersAtomic(providers);
|
|
150
|
+
return providers;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function saveProviders(providers: DiscoveredProvider[]): void {
|
|
154
|
+
writeProvidersAtomic(providers);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function upsertProvider(provider: DiscoveredProvider): void {
|
|
158
|
+
const all = loadProviders();
|
|
159
|
+
const index = all.findIndex((candidate) => candidate.name === provider.name);
|
|
160
|
+
if (index >= 0) all[index] = provider;
|
|
161
|
+
else all.push(provider);
|
|
162
|
+
saveProviders(all);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function deleteProvider(name: string): void {
|
|
166
|
+
saveProviders(loadProviders().filter((provider) => provider.name !== name));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function renameProvider(oldName: string, newName: string): boolean {
|
|
170
|
+
const all = loadProviders();
|
|
171
|
+
const index = all.findIndex((provider) => provider.name === oldName);
|
|
172
|
+
if (index < 0 || all.some((provider) => provider.name === newName)) return false;
|
|
173
|
+
all[index].name = newName;
|
|
174
|
+
saveProviders(all);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function persistProviderScanState(provider: DiscoveredProvider): void {
|
|
179
|
+
try {
|
|
180
|
+
const providers = loadProviders();
|
|
181
|
+
const stored = providers.find(
|
|
182
|
+
(candidate) => candidate.name === provider.name && candidate.baseUrl === provider.baseUrl,
|
|
183
|
+
);
|
|
184
|
+
if (!stored) return;
|
|
185
|
+
stored.serverType = provider.serverType;
|
|
186
|
+
stored.cachedModels = provider.cachedModels;
|
|
187
|
+
stored.lastScanned = provider.lastScanned;
|
|
188
|
+
stored.lastScanAttempt = provider.lastScanAttempt;
|
|
189
|
+
stored.lastScanError = provider.lastScanError;
|
|
190
|
+
saveProviders(providers);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Runtime registration must not fail merely because scan metadata could not be persisted.
|
|
193
|
+
console.error(`[model-discovery] ${provider.name}: could not persist catalogue state (${errorMessage(error)}).`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function recordSuccessfulScan(
|
|
198
|
+
provider: DiscoveredProvider,
|
|
199
|
+
models: Record<string, unknown>[],
|
|
200
|
+
serverType: string,
|
|
201
|
+
persist = true,
|
|
202
|
+
): void {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
provider.serverType = serverType;
|
|
205
|
+
provider.cachedModels = models;
|
|
206
|
+
provider.lastScanned = now;
|
|
207
|
+
provider.lastScanAttempt = now;
|
|
208
|
+
provider.lastScanError = undefined;
|
|
209
|
+
if (persist) persistProviderScanState(provider);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function recordFailedScan(provider: DiscoveredProvider, error: unknown, persist = true): void {
|
|
213
|
+
provider.lastScanAttempt = Date.now();
|
|
214
|
+
provider.lastScanError = redactSecret(errorMessage(error), provider.apiKey);
|
|
215
|
+
if (persist) persistProviderScanState(provider);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function getModelProfiles(provider: DiscoveredProvider, modelId: string): ModelProfile[] {
|
|
219
|
+
const profiles: unknown = provider.modelProfiles?.[modelId];
|
|
220
|
+
if (!Array.isArray(profiles)) return [];
|
|
221
|
+
return profiles.filter((profile): profile is ModelProfile => validateModelProfile(profile) === null);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function saveModelProfile(
|
|
225
|
+
provider: DiscoveredProvider,
|
|
226
|
+
modelId: string,
|
|
227
|
+
profile: ModelProfile,
|
|
228
|
+
previousSlug?: string,
|
|
229
|
+
): void {
|
|
230
|
+
const profiles = getModelProfiles(provider, modelId);
|
|
231
|
+
const index = previousSlug === undefined ? -1 : profiles.findIndex((item) => item.slug === previousSlug);
|
|
232
|
+
const next = [...profiles];
|
|
233
|
+
if (index >= 0) next[index] = profile;
|
|
234
|
+
else next.push(profile);
|
|
235
|
+
provider.modelProfiles = { ...provider.modelProfiles, [modelId]: next };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function deleteModelProfile(provider: DiscoveredProvider, modelId: string, slug: string): void {
|
|
239
|
+
const nextProfiles = getModelProfiles(provider, modelId).filter((profile) => profile.slug !== slug);
|
|
240
|
+
const modelProfiles = { ...provider.modelProfiles };
|
|
241
|
+
if (nextProfiles.length > 0) modelProfiles[modelId] = nextProfiles;
|
|
242
|
+
else delete modelProfiles[modelId];
|
|
243
|
+
provider.modelProfiles = Object.keys(modelProfiles).length > 0 ? modelProfiles : undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function getModelProfileRouting(
|
|
247
|
+
provider: DiscoveredProvider,
|
|
248
|
+
modelId: string,
|
|
249
|
+
): ModelProfileRouting | undefined {
|
|
250
|
+
return provider.modelProfileRouting?.[modelId];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function saveModelProfileRouting(
|
|
254
|
+
provider: DiscoveredProvider,
|
|
255
|
+
modelId: string,
|
|
256
|
+
routing: ModelProfileRouting,
|
|
257
|
+
): void {
|
|
258
|
+
provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: routing };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string): void {
|
|
262
|
+
const routing = { ...provider.modelProfileRouting };
|
|
263
|
+
delete routing[modelId];
|
|
264
|
+
provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
|
|
265
|
+
}
|