@maheidem/model-discovery 0.1.0 → 0.6.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/README.md +172 -22
- package/index.ts +1465 -86
- package/package.json +9 -3
- package/profiles.ts +619 -0
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maheidem/model-discovery",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Interactive TUI for discovering local AI endpoints and defining named thinking/sampling profiles (llama.cpp, oMLX, Ollama, vLLM, SGLang, LM Studio).",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"pi-package",
|
|
7
8
|
"extension",
|
|
8
9
|
"model-discovery",
|
|
10
|
+
"model-profiles",
|
|
11
|
+
"sampling",
|
|
9
12
|
"local-llm",
|
|
10
13
|
"llama.cpp",
|
|
11
14
|
"ollama",
|
|
@@ -15,8 +18,11 @@
|
|
|
15
18
|
"bugs": {
|
|
16
19
|
"url": "https://github.com/maheidem/model-discovery/issues"
|
|
17
20
|
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --experimental-strip-types --test profiles.test.ts offline.test.ts"
|
|
23
|
+
},
|
|
18
24
|
"peerDependencies": {
|
|
19
|
-
"@earendil-works/pi-coding-agent": "
|
|
25
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
20
26
|
"@earendil-works/pi-tui": "*",
|
|
21
27
|
"typebox": "*"
|
|
22
28
|
},
|
package/profiles.ts
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
export const REASONING_EFFORTS = ["low", "medium", "xhigh"] as const;
|
|
2
|
+
|
|
3
|
+
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
|
4
|
+
|
|
5
|
+
export interface ChatTemplateKwargs {
|
|
6
|
+
enable_thinking?: boolean;
|
|
7
|
+
reasoning_effort?: ReasoningEffort;
|
|
8
|
+
preserve_thinking?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Backend-neutral sampling values stored in a named profile. */
|
|
12
|
+
export interface ProfileSampling {
|
|
13
|
+
temperature?: number;
|
|
14
|
+
topP?: number;
|
|
15
|
+
topK?: number;
|
|
16
|
+
minP?: number;
|
|
17
|
+
repetitionPenalty?: number;
|
|
18
|
+
presencePenalty?: number;
|
|
19
|
+
frequencyPenalty?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ModelProfile {
|
|
23
|
+
slug: string;
|
|
24
|
+
chatTemplateKwargs?: ChatTemplateKwargs;
|
|
25
|
+
sampling?: ProfileSampling;
|
|
26
|
+
/** Whether this preset is also registered as a fixed model alias. Defaults to true. */
|
|
27
|
+
exposeAsModel?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type RepetitionPenaltyWireKey = "repetition_penalty" | "repeat_penalty";
|
|
31
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
32
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
33
|
+
export type ThinkingLevelMap = Partial<Record<ThinkingLevel, ThinkingLevel | null>>;
|
|
34
|
+
export type ThinkingProfileRoutes = Record<ThinkingLevel, ModelProfile>;
|
|
35
|
+
|
|
36
|
+
export interface ModelProfileRouting {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
aliasSlug: string;
|
|
39
|
+
levels: Record<ThinkingLevel, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExplicitProfileRoutingAnalysis {
|
|
43
|
+
routing?: ModelProfileRouting;
|
|
44
|
+
routes?: ThinkingProfileRoutes;
|
|
45
|
+
errors: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map Pi's seven native levels onto Qwen's three supported reasoning efforts.
|
|
50
|
+
* Keeping every Pi level available makes Shift-Tab work naturally; adjacent
|
|
51
|
+
* native levels intentionally share the closest Qwen effort.
|
|
52
|
+
*/
|
|
53
|
+
export const QWEN_NATIVE_THINKING_LEVEL_MAP: ThinkingLevelMap = {
|
|
54
|
+
minimal: "low",
|
|
55
|
+
low: "low",
|
|
56
|
+
medium: "medium",
|
|
57
|
+
high: "xhigh",
|
|
58
|
+
xhigh: "xhigh",
|
|
59
|
+
max: "xhigh",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Dynamic chat-template values resolved by Pi for each request. */
|
|
63
|
+
export const QWEN_NATIVE_CHAT_TEMPLATE_KWARGS = {
|
|
64
|
+
enable_thinking: { $var: "thinking.enabled" as const },
|
|
65
|
+
reasoning_effort: { $var: "thinking.effort" as const, omitWhenOff: true },
|
|
66
|
+
preserve_thinking: true,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface ProfileCapableModel {
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
reasoning: boolean;
|
|
73
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
74
|
+
compat?: Record<string, unknown>;
|
|
75
|
+
samplingParams?: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProfileExpansionOptions {
|
|
79
|
+
repetitionPenaltyKey?: RepetitionPenaltyWireKey;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ProfileExpansion<T extends ProfileCapableModel> {
|
|
83
|
+
models: Array<T & ProfileCapableModel>;
|
|
84
|
+
profileCount: number;
|
|
85
|
+
warnings: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PROFILE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
89
|
+
const CHAT_TEMPLATE_KEYS = new Set(["enable_thinking", "reasoning_effort", "preserve_thinking"]);
|
|
90
|
+
const PROFILE_SAMPLING_KEYS = new Set([
|
|
91
|
+
"temperature",
|
|
92
|
+
"topP",
|
|
93
|
+
"topK",
|
|
94
|
+
"minP",
|
|
95
|
+
"repetitionPenalty",
|
|
96
|
+
"presencePenalty",
|
|
97
|
+
"frequencyPenalty",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
101
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasOwn(object: object, key: string): boolean {
|
|
105
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
109
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateProfileSlug(slug: string): string | null {
|
|
113
|
+
if (!slug) return "Profile name cannot be empty.";
|
|
114
|
+
if (!PROFILE_SLUG_PATTERN.test(slug)) {
|
|
115
|
+
return "Use 1–64 characters: letters, numbers, dot, underscore, or hyphen; start with a letter or number.";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function validateChatTemplateKwargs(value: unknown): string | null {
|
|
121
|
+
if (!isRecord(value)) return "chat_template_kwargs must be an object.";
|
|
122
|
+
|
|
123
|
+
for (const key of Object.keys(value)) {
|
|
124
|
+
if (!CHAT_TEMPLATE_KEYS.has(key)) return `Unsupported chat_template_kwargs key: ${key}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hasOwn(value, "enable_thinking") && typeof value.enable_thinking !== "boolean") {
|
|
128
|
+
return "enable_thinking must be true or false.";
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
hasOwn(value, "reasoning_effort") &&
|
|
132
|
+
!REASONING_EFFORTS.includes(value.reasoning_effort as ReasoningEffort)
|
|
133
|
+
) {
|
|
134
|
+
return 'reasoning_effort must be "low", "medium", or "xhigh".';
|
|
135
|
+
}
|
|
136
|
+
if (hasOwn(value, "preserve_thinking") && typeof value.preserve_thinking !== "boolean") {
|
|
137
|
+
return "preserve_thinking must be true or false.";
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validateProfileSampling(value: unknown): string | null {
|
|
143
|
+
if (value === undefined) return null;
|
|
144
|
+
if (!isRecord(value)) return "sampling must be an object.";
|
|
145
|
+
|
|
146
|
+
for (const key of Object.keys(value)) {
|
|
147
|
+
if (!PROFILE_SAMPLING_KEYS.has(key)) return `Unsupported sampling key: ${key}`;
|
|
148
|
+
}
|
|
149
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
150
|
+
if (!isFiniteNumber(fieldValue)) return `${key} must be a finite number.`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const temperature = value.temperature;
|
|
154
|
+
if (temperature !== undefined && (!isFiniteNumber(temperature) || temperature < 0 || temperature > 2)) {
|
|
155
|
+
return "temperature must be between 0 and 2.";
|
|
156
|
+
}
|
|
157
|
+
const topP = value.topP;
|
|
158
|
+
if (topP !== undefined && (!isFiniteNumber(topP) || topP < 0 || topP > 1)) {
|
|
159
|
+
return "top_p must be between 0 and 1.";
|
|
160
|
+
}
|
|
161
|
+
const topK = value.topK;
|
|
162
|
+
if (topK !== undefined && (!isFiniteNumber(topK) || !Number.isInteger(topK) || topK < 0)) {
|
|
163
|
+
return "top_k must be an integer greater than or equal to 0.";
|
|
164
|
+
}
|
|
165
|
+
const minP = value.minP;
|
|
166
|
+
if (minP !== undefined && (!isFiniteNumber(minP) || minP < 0 || minP > 1)) {
|
|
167
|
+
return "min_p must be between 0 and 1.";
|
|
168
|
+
}
|
|
169
|
+
const repetitionPenalty = value.repetitionPenalty;
|
|
170
|
+
if (repetitionPenalty !== undefined && (!isFiniteNumber(repetitionPenalty) || repetitionPenalty <= 0)) {
|
|
171
|
+
return "repetition penalty must be greater than 0 (1 disables it).";
|
|
172
|
+
}
|
|
173
|
+
const presencePenalty = value.presencePenalty;
|
|
174
|
+
if (presencePenalty !== undefined && (!isFiniteNumber(presencePenalty) || presencePenalty < -2 || presencePenalty > 2)) {
|
|
175
|
+
return "presence_penalty must be between -2 and 2.";
|
|
176
|
+
}
|
|
177
|
+
const frequencyPenalty = value.frequencyPenalty;
|
|
178
|
+
if (frequencyPenalty !== undefined && (!isFiniteNumber(frequencyPenalty) || frequencyPenalty < -2 || frequencyPenalty > 2)) {
|
|
179
|
+
return "frequency_penalty must be between -2 and 2.";
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function validateModelProfile(value: unknown): string | null {
|
|
185
|
+
if (!isRecord(value)) return "Profile must be an object.";
|
|
186
|
+
if (typeof value.slug !== "string") return "Profile name must be a string.";
|
|
187
|
+
|
|
188
|
+
const chatTemplateKwargs = value.chatTemplateKwargs ?? {};
|
|
189
|
+
const slugError = validateProfileSlug(value.slug);
|
|
190
|
+
if (slugError) return slugError;
|
|
191
|
+
const chatError = validateChatTemplateKwargs(chatTemplateKwargs);
|
|
192
|
+
if (chatError) return chatError;
|
|
193
|
+
const samplingError = validateProfileSampling(value.sampling);
|
|
194
|
+
if (samplingError) return samplingError;
|
|
195
|
+
if (hasOwn(value, "exposeAsModel") && typeof value.exposeAsModel !== "boolean") {
|
|
196
|
+
return "exposeAsModel must be true or false.";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const hasThinking = Object.keys(chatTemplateKwargs as Record<string, unknown>).length > 0;
|
|
200
|
+
const hasSampling = isRecord(value.sampling) && Object.keys(value.sampling).length > 0;
|
|
201
|
+
if (!hasThinking && !hasSampling) return "Configure at least one thinking or sampling value.";
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function profileModelId(baseModelId: string, slug: string): string {
|
|
206
|
+
return `${baseModelId}@${slug}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function repetitionPenaltyKeyForServer(serverType: string): RepetitionPenaltyWireKey {
|
|
210
|
+
switch (serverType) {
|
|
211
|
+
case "llama.cpp":
|
|
212
|
+
case "LM Studio":
|
|
213
|
+
return "repeat_penalty";
|
|
214
|
+
case "oMLX":
|
|
215
|
+
case "vLLM":
|
|
216
|
+
case "SGLang":
|
|
217
|
+
case "Ollama":
|
|
218
|
+
case "OpenAI-compatible":
|
|
219
|
+
default:
|
|
220
|
+
// Ollama's OpenAI endpoint does not currently expose a dedicated
|
|
221
|
+
// repetition control. Use the ecosystem's common extension key as a
|
|
222
|
+
// best-effort fallback for unknown/OpenAI-compatible servers.
|
|
223
|
+
return "repetition_penalty";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function buildProfileSamplingParams(
|
|
228
|
+
sampling: ProfileSampling | undefined,
|
|
229
|
+
repetitionPenaltyKey: RepetitionPenaltyWireKey = "repetition_penalty",
|
|
230
|
+
): Record<string, unknown> {
|
|
231
|
+
if (!sampling) return {};
|
|
232
|
+
const params: Record<string, unknown> = {};
|
|
233
|
+
if (sampling.temperature !== undefined) params.temperature = sampling.temperature;
|
|
234
|
+
if (sampling.topP !== undefined) params.top_p = sampling.topP;
|
|
235
|
+
if (sampling.topK !== undefined) params.top_k = sampling.topK;
|
|
236
|
+
if (sampling.minP !== undefined) params.min_p = sampling.minP;
|
|
237
|
+
if (sampling.repetitionPenalty !== undefined) params[repetitionPenaltyKey] = sampling.repetitionPenalty;
|
|
238
|
+
if (sampling.presencePenalty !== undefined) params.presence_penalty = sampling.presencePenalty;
|
|
239
|
+
if (sampling.frequencyPenalty !== undefined) params.frequency_penalty = sampling.frequencyPenalty;
|
|
240
|
+
return params;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type ThinkingProfileKind = "off" | ReasoningEffort;
|
|
244
|
+
|
|
245
|
+
export interface ThinkingProfileRouteAnalysis {
|
|
246
|
+
byKind: Record<ThinkingProfileKind, ModelProfile[]>;
|
|
247
|
+
routes?: ThinkingProfileRoutes;
|
|
248
|
+
issues: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function thinkingProfileKind(profile: ModelProfile): ThinkingProfileKind | undefined {
|
|
252
|
+
const kwargs = profile.chatTemplateKwargs;
|
|
253
|
+
if (kwargs?.enable_thinking === false) return "off";
|
|
254
|
+
if (kwargs?.reasoning_effort !== undefined) return kwargs.reasoning_effort;
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Explain missing/ambiguous preset roles as well as returning a complete route. */
|
|
259
|
+
export function analyzeThinkingProfileRoutes(
|
|
260
|
+
profiles: readonly ModelProfile[] | undefined,
|
|
261
|
+
): ThinkingProfileRouteAnalysis {
|
|
262
|
+
const byKind: Record<ThinkingProfileKind, ModelProfile[]> = { off: [], low: [], medium: [], xhigh: [] };
|
|
263
|
+
for (const profile of profiles ?? []) {
|
|
264
|
+
if (validateModelProfile(profile) !== null) continue;
|
|
265
|
+
const kind = thinkingProfileKind(profile);
|
|
266
|
+
if (kind !== undefined) byKind[kind].push(profile);
|
|
267
|
+
}
|
|
268
|
+
const issues: string[] = [];
|
|
269
|
+
for (const kind of ["off", "low", "medium", "xhigh"] as const) {
|
|
270
|
+
const matching = byKind[kind];
|
|
271
|
+
if (matching.length === 0) issues.push(`missing ${kind}`);
|
|
272
|
+
else if (matching.length > 1) issues.push(`ambiguous ${kind}: ${matching.map((profile) => profile.slug).join(", ")}`);
|
|
273
|
+
}
|
|
274
|
+
if (issues.length > 0) return { byKind, issues };
|
|
275
|
+
const off = byKind.off[0];
|
|
276
|
+
const low = byKind.low[0];
|
|
277
|
+
const medium = byKind.medium[0];
|
|
278
|
+
const xhigh = byKind.xhigh[0];
|
|
279
|
+
return {
|
|
280
|
+
byKind,
|
|
281
|
+
issues,
|
|
282
|
+
routes: {
|
|
283
|
+
off,
|
|
284
|
+
minimal: low,
|
|
285
|
+
low,
|
|
286
|
+
medium,
|
|
287
|
+
high: xhigh,
|
|
288
|
+
xhigh,
|
|
289
|
+
max: xhigh,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build a complete native-level router when there is exactly one fixed profile
|
|
296
|
+
* for off, low, medium, and xhigh. Sampling-only profiles are deliberately
|
|
297
|
+
* ignored so they can themselves act as adaptive router aliases.
|
|
298
|
+
*/
|
|
299
|
+
export function resolveThinkingProfileRoutes(
|
|
300
|
+
profiles: readonly ModelProfile[] | undefined,
|
|
301
|
+
): ThinkingProfileRoutes | undefined {
|
|
302
|
+
return analyzeThinkingProfileRoutes(profiles).routes;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function routingLevelsFromProfiles(routes: ThinkingProfileRoutes): Record<ThinkingLevel, string> {
|
|
306
|
+
return Object.fromEntries(THINKING_LEVELS.map((level) => [level, routes[level].slug])) as Record<ThinkingLevel, string>;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Validate an explicit adaptive alias and resolve every level to its preset. */
|
|
310
|
+
export function analyzeExplicitProfileRouting(
|
|
311
|
+
value: unknown,
|
|
312
|
+
profiles: readonly ModelProfile[] | undefined,
|
|
313
|
+
): ExplicitProfileRoutingAnalysis {
|
|
314
|
+
const errors: string[] = [];
|
|
315
|
+
if (!isRecord(value)) return { errors: ["Routing configuration must be an object."] };
|
|
316
|
+
if (typeof value.enabled !== "boolean") errors.push("enabled must be true or false.");
|
|
317
|
+
if (typeof value.aliasSlug !== "string") errors.push("Adaptive alias must be a string.");
|
|
318
|
+
else {
|
|
319
|
+
const slugError = validateProfileSlug(value.aliasSlug);
|
|
320
|
+
if (slugError) errors.push(`Adaptive alias: ${slugError}`);
|
|
321
|
+
}
|
|
322
|
+
if (!isRecord(value.levels)) errors.push("Routing levels must be an object.");
|
|
323
|
+
|
|
324
|
+
const validProfiles = (profiles ?? []).filter((profile) => validateModelProfile(profile) === null);
|
|
325
|
+
const profilesBySlug = new Map<string, ModelProfile[]>();
|
|
326
|
+
for (const profile of validProfiles) {
|
|
327
|
+
const matching = profilesBySlug.get(profile.slug) ?? [];
|
|
328
|
+
matching.push(profile);
|
|
329
|
+
profilesBySlug.set(profile.slug, matching);
|
|
330
|
+
}
|
|
331
|
+
if (typeof value.aliasSlug === "string" && profilesBySlug.has(value.aliasSlug)) {
|
|
332
|
+
errors.push(`Adaptive alias "${value.aliasSlug}" collides with a preset name.`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const resolved = {} as ThinkingProfileRoutes;
|
|
336
|
+
if (isRecord(value.levels)) {
|
|
337
|
+
for (const key of Object.keys(value.levels)) {
|
|
338
|
+
if (!(THINKING_LEVELS as readonly string[]).includes(key)) errors.push(`Unsupported Pi level: ${key}`);
|
|
339
|
+
}
|
|
340
|
+
for (const level of THINKING_LEVELS) {
|
|
341
|
+
const slug = value.levels[level];
|
|
342
|
+
if (typeof slug !== "string" || !slug) {
|
|
343
|
+
errors.push(`Missing preset mapping for ${level}.`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const matching = profilesBySlug.get(slug) ?? [];
|
|
347
|
+
if (matching.length === 0) errors.push(`${level} references missing preset "${slug}".`);
|
|
348
|
+
else if (matching.length > 1) errors.push(`${level} references duplicate preset name "${slug}".`);
|
|
349
|
+
else resolved[level] = matching[0];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (errors.length > 0) return { errors };
|
|
354
|
+
return {
|
|
355
|
+
errors,
|
|
356
|
+
routing: value as unknown as ModelProfileRouting,
|
|
357
|
+
routes: resolved,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface LegacyProfileRoutingMigration {
|
|
362
|
+
profiles: ModelProfile[];
|
|
363
|
+
routing?: ModelProfileRouting;
|
|
364
|
+
changed: boolean;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** One-time conservative migration for the previously shipped implicit router. */
|
|
368
|
+
export function migrateLegacyProfileRouting(
|
|
369
|
+
profiles: readonly ModelProfile[],
|
|
370
|
+
existingRouting: unknown,
|
|
371
|
+
): LegacyProfileRoutingMigration {
|
|
372
|
+
if (existingRouting !== undefined) return { profiles: [...profiles], changed: false };
|
|
373
|
+
const routes = resolveThinkingProfileRoutes(profiles);
|
|
374
|
+
if (!routes) return { profiles: [...profiles], changed: false };
|
|
375
|
+
const adaptiveCandidates = profiles.filter(
|
|
376
|
+
(profile) =>
|
|
377
|
+
(profile.chatTemplateKwargs === undefined || Object.keys(profile.chatTemplateKwargs).length === 0) &&
|
|
378
|
+
profile.sampling !== undefined,
|
|
379
|
+
);
|
|
380
|
+
if (adaptiveCandidates.length !== 1) return { profiles: [...profiles], changed: false };
|
|
381
|
+
const adaptive = adaptiveCandidates[0];
|
|
382
|
+
return {
|
|
383
|
+
profiles: profiles.filter((profile) => profile !== adaptive),
|
|
384
|
+
routing: {
|
|
385
|
+
enabled: true,
|
|
386
|
+
aliasSlug: adaptive.slug,
|
|
387
|
+
levels: routingLevelsFromProfiles(routes),
|
|
388
|
+
},
|
|
389
|
+
changed: true,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const PROFILE_CONTROLLED_WIRE_KEYS = [
|
|
394
|
+
"enable_thinking",
|
|
395
|
+
"reasoning_effort",
|
|
396
|
+
"temperature",
|
|
397
|
+
"top_p",
|
|
398
|
+
"top_k",
|
|
399
|
+
"min_p",
|
|
400
|
+
"repetition_penalty",
|
|
401
|
+
"repeat_penalty",
|
|
402
|
+
"presence_penalty",
|
|
403
|
+
"frequency_penalty",
|
|
404
|
+
] as const;
|
|
405
|
+
|
|
406
|
+
/** Replace every profile-controlled wire field with one routed profile. */
|
|
407
|
+
export function applyThinkingProfileRoute(
|
|
408
|
+
payload: unknown,
|
|
409
|
+
profile: ModelProfile,
|
|
410
|
+
repetitionPenaltyKey: RepetitionPenaltyWireKey = "repetition_penalty",
|
|
411
|
+
): unknown {
|
|
412
|
+
if (!isRecord(payload)) return payload;
|
|
413
|
+
const next = { ...payload };
|
|
414
|
+
for (const key of PROFILE_CONTROLLED_WIRE_KEYS) delete next[key];
|
|
415
|
+
Object.assign(next, buildProfileSamplingParams(profile.sampling, repetitionPenaltyKey));
|
|
416
|
+
const kwargs = profile.chatTemplateKwargs;
|
|
417
|
+
if (kwargs && Object.keys(kwargs).length > 0) next.chat_template_kwargs = { ...kwargs };
|
|
418
|
+
else delete next.chat_template_kwargs;
|
|
419
|
+
return next;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function describeChatTemplateKwargs(kwargs: ChatTemplateKwargs | undefined): string {
|
|
423
|
+
const values: string[] = [];
|
|
424
|
+
if (kwargs?.enable_thinking !== undefined) values.push(`thinking ${kwargs.enable_thinking ? "on" : "off"}`);
|
|
425
|
+
if (kwargs?.reasoning_effort !== undefined) values.push(`effort ${kwargs.reasoning_effort}`);
|
|
426
|
+
if (kwargs?.preserve_thinking !== undefined) values.push(`preserve ${kwargs.preserve_thinking ? "on" : "off"}`);
|
|
427
|
+
return values.join(" · ");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function describeProfileSampling(
|
|
431
|
+
sampling: ProfileSampling | undefined,
|
|
432
|
+
repetitionPenaltyKey: RepetitionPenaltyWireKey = "repetition_penalty",
|
|
433
|
+
): string {
|
|
434
|
+
return Object.entries(buildProfileSamplingParams(sampling, repetitionPenaltyKey))
|
|
435
|
+
.map(([key, value]) => `${key} ${value}`)
|
|
436
|
+
.join(" · ");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function fixedThinkingLevelMap(
|
|
440
|
+
kwargs: ChatTemplateKwargs,
|
|
441
|
+
base: ProfileCapableModel,
|
|
442
|
+
): ThinkingLevelMap | undefined {
|
|
443
|
+
const { enable_thinking, reasoning_effort } = kwargs;
|
|
444
|
+
if (enable_thinking === false) return undefined;
|
|
445
|
+
|
|
446
|
+
if (reasoning_effort !== undefined) {
|
|
447
|
+
return {
|
|
448
|
+
off: null,
|
|
449
|
+
minimal: null,
|
|
450
|
+
low: reasoning_effort === "low" ? "low" : null,
|
|
451
|
+
medium: reasoning_effort === "medium" ? "medium" : null,
|
|
452
|
+
high: null,
|
|
453
|
+
xhigh: reasoning_effort === "xhigh" ? "xhigh" : null,
|
|
454
|
+
max: null,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Pi has no generic on/off reasoning level. Lock an explicitly enabled profile
|
|
459
|
+
// to one visible level so the UI cannot imply that a different effort is sent.
|
|
460
|
+
if (enable_thinking === true) {
|
|
461
|
+
return {
|
|
462
|
+
off: null,
|
|
463
|
+
minimal: null,
|
|
464
|
+
low: null,
|
|
465
|
+
medium: null,
|
|
466
|
+
high: "high",
|
|
467
|
+
xhigh: null,
|
|
468
|
+
max: null,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return base.thinkingLevelMap;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Add selectable alias models for valid profiles. The alias remains Pi's model
|
|
477
|
+
* identity while samplingParams rewrites the OpenAI-compatible request to the
|
|
478
|
+
* real server model and the profile's fixed thinking/sampling values.
|
|
479
|
+
*/
|
|
480
|
+
export function expandModelProfiles<T extends ProfileCapableModel>(
|
|
481
|
+
baseModels: readonly T[],
|
|
482
|
+
profilesByModel: Record<string, ModelProfile[]> | undefined,
|
|
483
|
+
options: ProfileExpansionOptions = {},
|
|
484
|
+
): ProfileExpansion<T> {
|
|
485
|
+
const models = baseModels.map((model) => ({ ...model })) as Array<T & ProfileCapableModel>;
|
|
486
|
+
const warnings: string[] = [];
|
|
487
|
+
const usedModelIds = new Set(baseModels.map((model) => model.id));
|
|
488
|
+
const baseModelIds = new Set(usedModelIds);
|
|
489
|
+
const repetitionPenaltyKey = options.repetitionPenaltyKey ?? "repetition_penalty";
|
|
490
|
+
let profileCount = 0;
|
|
491
|
+
|
|
492
|
+
for (const base of baseModels) {
|
|
493
|
+
const rawProfiles: unknown = profilesByModel?.[base.id];
|
|
494
|
+
if (rawProfiles === undefined) continue;
|
|
495
|
+
if (!Array.isArray(rawProfiles)) {
|
|
496
|
+
warnings.push(`Profiles for "${base.id}" are not an array and were skipped.`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
for (const rawProfile of rawProfiles) {
|
|
501
|
+
const error = validateModelProfile(rawProfile);
|
|
502
|
+
if (error) {
|
|
503
|
+
warnings.push(`Invalid profile for "${base.id}": ${error}`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const profile = rawProfile as ModelProfile;
|
|
507
|
+
if (profile.exposeAsModel === false) continue;
|
|
508
|
+
const aliasId = profileModelId(base.id, profile.slug);
|
|
509
|
+
if (usedModelIds.has(aliasId)) {
|
|
510
|
+
warnings.push(`Profile "${aliasId}" collides with another model and was skipped.`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const kwargs = profile.chatTemplateKwargs ?? {};
|
|
515
|
+
const hasThinkingValues = Object.keys(kwargs).length > 0;
|
|
516
|
+
const enabled = kwargs.enable_thinking;
|
|
517
|
+
const effort = kwargs.reasoning_effort;
|
|
518
|
+
const reasoning = hasThinkingValues
|
|
519
|
+
? enabled === false
|
|
520
|
+
? false
|
|
521
|
+
: enabled === true || effort !== undefined
|
|
522
|
+
? true
|
|
523
|
+
: base.reasoning
|
|
524
|
+
: base.reasoning;
|
|
525
|
+
const profileModel = {
|
|
526
|
+
...base,
|
|
527
|
+
id: aliasId,
|
|
528
|
+
name: `${base.name} (${profile.slug})`,
|
|
529
|
+
reasoning,
|
|
530
|
+
thinkingLevelMap: hasThinkingValues
|
|
531
|
+
? reasoning
|
|
532
|
+
? fixedThinkingLevelMap(kwargs, base)
|
|
533
|
+
: undefined
|
|
534
|
+
: base.thinkingLevelMap,
|
|
535
|
+
compat: hasThinkingValues
|
|
536
|
+
? {
|
|
537
|
+
...base.compat,
|
|
538
|
+
thinkingFormat: "chat-template",
|
|
539
|
+
chatTemplateKwargs: {},
|
|
540
|
+
supportsReasoningEffort: false,
|
|
541
|
+
}
|
|
542
|
+
: base.compat,
|
|
543
|
+
samplingParams: {
|
|
544
|
+
...base.samplingParams,
|
|
545
|
+
model: base.id,
|
|
546
|
+
...buildProfileSamplingParams(profile.sampling, repetitionPenaltyKey),
|
|
547
|
+
...(hasThinkingValues ? { chat_template_kwargs: { ...kwargs } } : {}),
|
|
548
|
+
},
|
|
549
|
+
} as T & ProfileCapableModel;
|
|
550
|
+
|
|
551
|
+
models.push(profileModel);
|
|
552
|
+
usedModelIds.add(aliasId);
|
|
553
|
+
profileCount++;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
for (const [modelId, rawProfiles] of Object.entries(profilesByModel ?? {})) {
|
|
558
|
+
if (!baseModelIds.has(modelId) && Array.isArray(rawProfiles) && rawProfiles.length > 0) {
|
|
559
|
+
warnings.push(`Profiles for missing model "${modelId}" were retained but not registered.`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { models, profileCount, warnings };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export interface AdaptiveRouterExpansion<T extends ProfileCapableModel> {
|
|
567
|
+
models: Array<T & ProfileCapableModel>;
|
|
568
|
+
routerCount: number;
|
|
569
|
+
warnings: string[];
|
|
570
|
+
runtimeRoutes: Map<string, ThinkingProfileRoutes>;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Add only explicitly enabled adaptive aliases; base and preset aliases are untouched. */
|
|
574
|
+
export function expandAdaptiveProfileRouters<T extends ProfileCapableModel>(
|
|
575
|
+
baseModels: readonly T[],
|
|
576
|
+
profilesByModel: Record<string, ModelProfile[]> | undefined,
|
|
577
|
+
routingByModel: Record<string, ModelProfileRouting> | undefined,
|
|
578
|
+
): AdaptiveRouterExpansion<T> {
|
|
579
|
+
const models = baseModels.map((model) => ({ ...model })) as Array<T & ProfileCapableModel>;
|
|
580
|
+
const warnings: string[] = [];
|
|
581
|
+
const runtimeRoutes = new Map<string, ThinkingProfileRoutes>();
|
|
582
|
+
const usedModelIds = new Set(baseModels.map((model) => model.id));
|
|
583
|
+
let routerCount = 0;
|
|
584
|
+
|
|
585
|
+
for (const base of baseModels) {
|
|
586
|
+
const rawRouting: unknown = routingByModel?.[base.id];
|
|
587
|
+
if (rawRouting === undefined) continue;
|
|
588
|
+
const profiles = Array.isArray(profilesByModel?.[base.id]) ? profilesByModel?.[base.id] : [];
|
|
589
|
+
const analysis = analyzeExplicitProfileRouting(rawRouting, profiles);
|
|
590
|
+
if (analysis.errors.length > 0) {
|
|
591
|
+
warnings.push(`Invalid adaptive routing for "${base.id}": ${analysis.errors.join(" ")}`);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (!analysis.routing?.enabled || !analysis.routes) continue;
|
|
595
|
+
const aliasId = profileModelId(base.id, analysis.routing.aliasSlug);
|
|
596
|
+
if (usedModelIds.has(aliasId)) {
|
|
597
|
+
warnings.push(`Adaptive alias "${aliasId}" collides with another model and was skipped.`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
models.push({
|
|
601
|
+
...base,
|
|
602
|
+
id: aliasId,
|
|
603
|
+
name: `${base.name} (${analysis.routing.aliasSlug}; adaptive)`,
|
|
604
|
+
reasoning: true,
|
|
605
|
+
thinkingLevelMap: { ...QWEN_NATIVE_THINKING_LEVEL_MAP },
|
|
606
|
+
samplingParams: { ...base.samplingParams, model: base.id },
|
|
607
|
+
} as T & ProfileCapableModel);
|
|
608
|
+
usedModelIds.add(aliasId);
|
|
609
|
+
runtimeRoutes.set(aliasId, analysis.routes);
|
|
610
|
+
routerCount++;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const modelId of Object.keys(routingByModel ?? {})) {
|
|
614
|
+
if (!baseModels.some((model) => model.id === modelId)) {
|
|
615
|
+
warnings.push(`Adaptive routing for missing model "${modelId}" was retained but not registered.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { models, routerCount, warnings, runtimeRoutes };
|
|
619
|
+
}
|