@klhapp/skillmux 1.0.1 → 1.1.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/CHANGELOG.md +43 -0
- package/README.md +77 -28
- package/config.remote.example.toml +3 -3
- package/docs/configuration.md +39 -4
- package/docs/schema.json +13 -5
- package/package.json +1 -1
- package/src/adapters.ts +98 -18
- package/src/calibrate.ts +461 -119
- package/src/cli.ts +44 -2
- package/src/clients.ts +264 -48
- package/src/config-service.ts +25 -8
- package/src/config-watcher.ts +7 -0
- package/src/config.ts +106 -25
- package/src/decision.ts +4 -1
- package/src/doctor.ts +16 -5
- package/src/eval.ts +2 -1
- package/src/router-core.ts +73 -42
- package/src/server.ts +8 -5
- package/src/types.ts +3 -3
package/src/cli.ts
CHANGED
|
@@ -399,10 +399,52 @@ async function handleCalibrateCommand(
|
|
|
399
399
|
) {
|
|
400
400
|
if (sub === "run") {
|
|
401
401
|
let datasetPath: string | undefined;
|
|
402
|
+
let minAutoMatchPrecision: number | undefined;
|
|
403
|
+
let minRetrievalRecallAtK: number | undefined;
|
|
404
|
+
let minDeliveredShortlistRecallAtK: number | undefined;
|
|
405
|
+
let minAutoMatchCount: number | undefined;
|
|
406
|
+
const readNumber = (flag: string, raw: string | undefined): number => {
|
|
407
|
+
if (raw === undefined) throw new Error(`${flag} requires a value`);
|
|
408
|
+
const value = Number(raw);
|
|
409
|
+
if (!Number.isFinite(value)) throw new Error(`${flag} must be a number`);
|
|
410
|
+
return value;
|
|
411
|
+
};
|
|
402
412
|
for (let i = 0; i < args.length; i++) {
|
|
403
|
-
|
|
413
|
+
const option = args[i];
|
|
414
|
+
if (option === "--dataset") {
|
|
415
|
+
datasetPath = args[++i];
|
|
416
|
+
if (!datasetPath) throw new Error("--dataset requires a path value");
|
|
417
|
+
} else if (option === "--min-auto-match-precision") {
|
|
418
|
+
minAutoMatchPrecision = readNumber(option, args[++i]);
|
|
419
|
+
} else if (option === "--min-retrieval-recall-at-k") {
|
|
420
|
+
minRetrievalRecallAtK = readNumber(option, args[++i]);
|
|
421
|
+
} else if (option === "--min-delivered-shortlist-recall-at-k") {
|
|
422
|
+
minDeliveredShortlistRecallAtK = readNumber(option, args[++i]);
|
|
423
|
+
} else if (option === "--min-auto-match-count") {
|
|
424
|
+
minAutoMatchCount = readNumber(option, args[++i]);
|
|
425
|
+
if (!Number.isInteger(minAutoMatchCount) || minAutoMatchCount < 1) {
|
|
426
|
+
throw new Error("--min-auto-match-count must be a positive integer");
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
throw new Error(`unknown calibrate run option: ${option}`);
|
|
430
|
+
}
|
|
404
431
|
}
|
|
405
|
-
const
|
|
432
|
+
for (const [flag, value] of [
|
|
433
|
+
["--min-auto-match-precision", minAutoMatchPrecision],
|
|
434
|
+
["--min-retrieval-recall-at-k", minRetrievalRecallAtK],
|
|
435
|
+
["--min-delivered-shortlist-recall-at-k", minDeliveredShortlistRecallAtK],
|
|
436
|
+
] as const) {
|
|
437
|
+
if (value !== undefined && (value < 0 || value > 1)) {
|
|
438
|
+
throw new Error(`${flag} must be between 0 and 1`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const res = await adapter.calibrateRun({
|
|
442
|
+
datasetPath,
|
|
443
|
+
minAutoMatchPrecision,
|
|
444
|
+
minRetrievalRecallAtK,
|
|
445
|
+
minDeliveredShortlistRecallAtK,
|
|
446
|
+
minAutoMatchCount,
|
|
447
|
+
});
|
|
406
448
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
407
449
|
renderTargetBanner(ctx.target);
|
|
408
450
|
console.log(`Calibration run complete.`);
|
package/src/clients.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import type { Clients, Config } from "./types";
|
|
1
|
+
import type { Clients, Config, RemoteRerankerConfig } from "./types";
|
|
2
2
|
import { expandHome } from "./config";
|
|
3
3
|
import type { pipeline as createPipeline } from "@huggingface/transformers";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
data: { index: number; embedding: number[] }[];
|
|
7
|
-
}
|
|
5
|
+
export type RemoteErrorKind = "configuration" | "availability" | "protocol";
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
export class RemoteInferenceError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly kind: RemoteErrorKind,
|
|
10
|
+
message: string,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "RemoteInferenceError";
|
|
14
|
+
}
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
// Lazy-loaded model instances for in-process ONNX inference
|
|
@@ -15,6 +19,220 @@ type FeatureExtractor = Awaited<ReturnType<typeof createPipeline<"feature-extrac
|
|
|
15
19
|
|
|
16
20
|
let localEmbedder: FeatureExtractor | null = null;
|
|
17
21
|
|
|
22
|
+
function authorizationHeaders(
|
|
23
|
+
apiKeyEnv: string | undefined,
|
|
24
|
+
configKey: string,
|
|
25
|
+
): Record<string, string> {
|
|
26
|
+
if (apiKeyEnv === undefined) return {};
|
|
27
|
+
const apiKey = process.env[apiKeyEnv];
|
|
28
|
+
if (apiKey === undefined || apiKey === "") {
|
|
29
|
+
throw new RemoteInferenceError(
|
|
30
|
+
"configuration",
|
|
31
|
+
`${configKey} names environment variable "${apiKeyEnv}", but it is unset or empty.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return { authorization: `Bearer ${apiKey}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function httpFailure(surface: string, status: number): RemoteInferenceError {
|
|
38
|
+
const kind: RemoteErrorKind =
|
|
39
|
+
status === 401 || status === 403
|
|
40
|
+
? "configuration"
|
|
41
|
+
: status === 408 || status === 429 || status >= 500
|
|
42
|
+
? "availability"
|
|
43
|
+
: "protocol";
|
|
44
|
+
return new RemoteInferenceError(kind, `${surface} returned HTTP ${status}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function finiteFloat32(
|
|
48
|
+
values: unknown[],
|
|
49
|
+
error: () => Error,
|
|
50
|
+
): Float32Array {
|
|
51
|
+
const result = new Float32Array(values.length);
|
|
52
|
+
for (let index = 0; index < values.length; index++) {
|
|
53
|
+
const value = values[index];
|
|
54
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw error();
|
|
55
|
+
result[index] = value;
|
|
56
|
+
if (!Number.isFinite(result[index]!)) throw error();
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseEmbeddingVectors(
|
|
62
|
+
value: unknown,
|
|
63
|
+
inputCount: number,
|
|
64
|
+
dimension: number,
|
|
65
|
+
): Float32Array[] {
|
|
66
|
+
const response = value as { data?: unknown };
|
|
67
|
+
if (
|
|
68
|
+
typeof response !== "object" ||
|
|
69
|
+
response === null ||
|
|
70
|
+
!Array.isArray(response.data) ||
|
|
71
|
+
response.data.length !== inputCount
|
|
72
|
+
) {
|
|
73
|
+
throw new RemoteInferenceError(
|
|
74
|
+
"protocol",
|
|
75
|
+
"embedding endpoint returned an incomplete data array",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const vectors = new Array<Float32Array>(inputCount);
|
|
80
|
+
const seen = new Set<number>();
|
|
81
|
+
for (const value of response.data) {
|
|
82
|
+
const entry = value as { index?: unknown; embedding?: unknown };
|
|
83
|
+
if (
|
|
84
|
+
typeof entry !== "object" ||
|
|
85
|
+
entry === null ||
|
|
86
|
+
!Number.isInteger(entry.index) ||
|
|
87
|
+
(entry.index as number) < 0 ||
|
|
88
|
+
(entry.index as number) >= inputCount ||
|
|
89
|
+
seen.has(entry.index as number) ||
|
|
90
|
+
!Array.isArray(entry.embedding) ||
|
|
91
|
+
entry.embedding.length !== dimension
|
|
92
|
+
) {
|
|
93
|
+
throw new RemoteInferenceError(
|
|
94
|
+
"protocol",
|
|
95
|
+
"embedding endpoint returned invalid indexed vectors",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
seen.add(entry.index as number);
|
|
99
|
+
vectors[entry.index as number] = finiteFloat32(
|
|
100
|
+
entry.embedding,
|
|
101
|
+
() => new RemoteInferenceError("protocol", "embedding endpoint returned invalid vector values"),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return vectors;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseLocalEmbeddingVectors(
|
|
108
|
+
value: unknown,
|
|
109
|
+
inputCount: number,
|
|
110
|
+
dimension: number,
|
|
111
|
+
): Float32Array[] {
|
|
112
|
+
if (!Array.isArray(value) || value.length !== inputCount) {
|
|
113
|
+
throw new Error("Embedding model returned an unexpected batch size.");
|
|
114
|
+
}
|
|
115
|
+
return value.map((row) => {
|
|
116
|
+
if (!Array.isArray(row) || row.length !== dimension) {
|
|
117
|
+
throw new Error("Embedding model returned unexpected vector dimensions.");
|
|
118
|
+
}
|
|
119
|
+
return finiteFloat32(
|
|
120
|
+
row,
|
|
121
|
+
() => new Error("Embedding model returned invalid vector values."),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rerankerRequestBody(
|
|
127
|
+
reranker: RemoteRerankerConfig,
|
|
128
|
+
query: string,
|
|
129
|
+
docs: { skill_id: string; text: string }[],
|
|
130
|
+
): Record<string, unknown> {
|
|
131
|
+
if (reranker.adapter === "jina-v1") {
|
|
132
|
+
return {
|
|
133
|
+
model: reranker.model,
|
|
134
|
+
query,
|
|
135
|
+
documents: docs.map((doc) => doc.text),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
model: reranker.model,
|
|
140
|
+
query,
|
|
141
|
+
documents: docs.map((doc) => ({
|
|
142
|
+
text: doc.text,
|
|
143
|
+
id: doc.skill_id,
|
|
144
|
+
meta: {},
|
|
145
|
+
})),
|
|
146
|
+
top_n: docs.length,
|
|
147
|
+
return_documents: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseRerankerScores(
|
|
152
|
+
adapter: RemoteRerankerConfig["adapter"],
|
|
153
|
+
value: unknown,
|
|
154
|
+
documentCount: number,
|
|
155
|
+
): number[] {
|
|
156
|
+
const response = value as { results?: unknown };
|
|
157
|
+
if (
|
|
158
|
+
typeof response !== "object" ||
|
|
159
|
+
response === null ||
|
|
160
|
+
!Array.isArray(response.results) ||
|
|
161
|
+
response.results.length !== documentCount
|
|
162
|
+
) {
|
|
163
|
+
throw new RemoteInferenceError(
|
|
164
|
+
"protocol",
|
|
165
|
+
`reranker adapter "${adapter}" returned an incomplete results array`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const scores = new Array<number>(documentCount);
|
|
170
|
+
const seen = new Set<number>();
|
|
171
|
+
for (const value of response.results) {
|
|
172
|
+
const result = value as { index?: unknown; relevance_score?: unknown };
|
|
173
|
+
if (
|
|
174
|
+
typeof result !== "object" ||
|
|
175
|
+
result === null ||
|
|
176
|
+
!Number.isInteger(result.index) ||
|
|
177
|
+
(result.index as number) < 0 ||
|
|
178
|
+
(result.index as number) >= documentCount ||
|
|
179
|
+
seen.has(result.index as number) ||
|
|
180
|
+
typeof result.relevance_score !== "number" ||
|
|
181
|
+
!Number.isFinite(result.relevance_score)
|
|
182
|
+
) {
|
|
183
|
+
throw new RemoteInferenceError(
|
|
184
|
+
"protocol",
|
|
185
|
+
`reranker adapter "${adapter}" returned invalid indexed scores`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
seen.add(result.index as number);
|
|
189
|
+
scores[result.index as number] = result.relevance_score;
|
|
190
|
+
}
|
|
191
|
+
return scores;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function fetchRerankerScores(
|
|
195
|
+
reranker: RemoteRerankerConfig,
|
|
196
|
+
timeoutMs: number,
|
|
197
|
+
query: string,
|
|
198
|
+
docs: { skill_id: string; text: string }[],
|
|
199
|
+
): Promise<number[]> {
|
|
200
|
+
if (docs.length === 0) return [];
|
|
201
|
+
|
|
202
|
+
let response: Response;
|
|
203
|
+
try {
|
|
204
|
+
response = await fetch(reranker.endpoint, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"content-type": "application/json",
|
|
208
|
+
...authorizationHeaders(reranker.api_key_env, "inference.reranker.api_key_env"),
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify(rerankerRequestBody(reranker, query, docs)),
|
|
211
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof RemoteInferenceError) throw error;
|
|
215
|
+
throw new RemoteInferenceError(
|
|
216
|
+
"availability",
|
|
217
|
+
`reranker adapter "${reranker.adapter}" request failed`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw httpFailure(`reranker adapter "${reranker.adapter}"`, response.status);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let parsed: unknown;
|
|
225
|
+
try {
|
|
226
|
+
parsed = await response.json();
|
|
227
|
+
} catch {
|
|
228
|
+
throw new RemoteInferenceError(
|
|
229
|
+
"protocol",
|
|
230
|
+
`reranker adapter "${reranker.adapter}" returned malformed JSON`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return parseRerankerScores(reranker.adapter, parsed, docs.length);
|
|
234
|
+
}
|
|
235
|
+
|
|
18
236
|
function localInference(config: Config) {
|
|
19
237
|
if (config.inference.mode !== "local") throw new Error("Local inference is not configured.");
|
|
20
238
|
return config.inference;
|
|
@@ -56,37 +274,45 @@ export function createClients(config: Config): Clients {
|
|
|
56
274
|
const pipe = await getLocalEmbedder(config);
|
|
57
275
|
const output = await pipe(texts, { pooling: "mean", normalize: true });
|
|
58
276
|
const dim = output.dims[1];
|
|
59
|
-
if (
|
|
277
|
+
if (
|
|
278
|
+
dim === undefined ||
|
|
279
|
+
output.dims.length !== 2 ||
|
|
280
|
+
output.dims[0] !== texts.length ||
|
|
281
|
+
dim !== config.inference.embedding.dimension
|
|
282
|
+
) {
|
|
60
283
|
throw new Error(`Embedding model returned unexpected dimensions: ${output.dims.join("x")}`);
|
|
61
284
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new Error("Embedding model returned non-numeric values.");
|
|
67
|
-
}
|
|
68
|
-
result.push(Float32Array.from(row));
|
|
69
|
-
}
|
|
70
|
-
return result;
|
|
285
|
+
const rows = Array.from({ length: texts.length }, (_, index) =>
|
|
286
|
+
output.slice(index, null).tolist(),
|
|
287
|
+
);
|
|
288
|
+
return parseLocalEmbeddingVectors(rows, texts.length, dim);
|
|
71
289
|
}
|
|
72
290
|
|
|
73
291
|
const embedding = config.inference.embedding;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
292
|
+
let response: Response;
|
|
293
|
+
try {
|
|
294
|
+
response = await fetch(embedding.endpoint, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: {
|
|
297
|
+
"content-type": "application/json",
|
|
298
|
+
...authorizationHeaders(embedding.api_key_env, "inference.embedding.api_key_env"),
|
|
299
|
+
},
|
|
300
|
+
body: JSON.stringify({ model: embedding.model, input: texts }),
|
|
301
|
+
signal: AbortSignal.timeout(config.inference.timeout_ms),
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof RemoteInferenceError) throw error;
|
|
305
|
+
throw new RemoteInferenceError("availability", "embedding endpoint request failed");
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) throw httpFailure("embedding endpoint", response.status);
|
|
308
|
+
|
|
309
|
+
let parsed: unknown;
|
|
310
|
+
try {
|
|
311
|
+
parsed = await response.json();
|
|
312
|
+
} catch {
|
|
313
|
+
throw new RemoteInferenceError("protocol", "embedding endpoint returned malformed JSON");
|
|
314
|
+
}
|
|
315
|
+
return parseEmbeddingVectors(parsed, texts.length, embedding.dimension);
|
|
90
316
|
},
|
|
91
317
|
};
|
|
92
318
|
if (config.inference.mode === "remote" && config.inference.reranker) {
|
|
@@ -94,22 +320,12 @@ export function createClients(config: Config): Clients {
|
|
|
94
320
|
clients.rerank = async (query, docs) => {
|
|
95
321
|
const reranker = inference.reranker;
|
|
96
322
|
if (!reranker) throw new Error("Reranker is not configured.");
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
query,
|
|
104
|
-
documents: docs.map((d) => d.text),
|
|
105
|
-
}),
|
|
106
|
-
signal: AbortSignal.timeout(inference.timeout_ms),
|
|
107
|
-
});
|
|
108
|
-
if (!response.ok) throw new Error(`rerank endpoint returned ${response.status}`);
|
|
109
|
-
const parsed = (await response.json()) as RerankResponse;
|
|
110
|
-
const scores = new Array<number>(docs.length).fill(0);
|
|
111
|
-
for (const result of parsed.results) scores[result.index] = result.relevance_score;
|
|
112
|
-
return scores;
|
|
323
|
+
return fetchRerankerScores(
|
|
324
|
+
reranker,
|
|
325
|
+
inference.timeout_ms,
|
|
326
|
+
query,
|
|
327
|
+
docs,
|
|
328
|
+
);
|
|
113
329
|
};
|
|
114
330
|
}
|
|
115
331
|
return clients;
|
package/src/config-service.ts
CHANGED
|
@@ -44,6 +44,10 @@ export const RESTART_REQUIRED_KEYS = [
|
|
|
44
44
|
"inference.bundle",
|
|
45
45
|
"inference.models_dir",
|
|
46
46
|
"state_dir",
|
|
47
|
+
"inference.embedding.model",
|
|
48
|
+
"inference.embedding.dimension",
|
|
49
|
+
"inference.embedding.device",
|
|
50
|
+
"inference.embedding.dtype",
|
|
47
51
|
];
|
|
48
52
|
|
|
49
53
|
export const RELOADABLE_KEYS = [
|
|
@@ -54,12 +58,13 @@ export const RELOADABLE_KEYS = [
|
|
|
54
58
|
"thresholds.match_score",
|
|
55
59
|
"thresholds.match_margin",
|
|
56
60
|
"thresholds.candidate_floor",
|
|
57
|
-
"inference.embedding.
|
|
58
|
-
"inference.embedding.dimension",
|
|
59
|
-
"inference.embedding.device",
|
|
60
|
-
"inference.embedding.dtype",
|
|
61
|
-
"inference.embedding.base_url",
|
|
61
|
+
"inference.embedding.endpoint",
|
|
62
62
|
"inference.embedding.api_key_env",
|
|
63
|
+
"inference.reranker.adapter",
|
|
64
|
+
"inference.reranker.endpoint",
|
|
65
|
+
"inference.reranker.model",
|
|
66
|
+
"inference.reranker.api_key_env",
|
|
67
|
+
"inference.timeout_ms",
|
|
63
68
|
"server.rate_limit.enabled",
|
|
64
69
|
"server.rate_limit.requests_per_minute",
|
|
65
70
|
"server.rate_limit.trust_proxy",
|
|
@@ -146,8 +151,12 @@ export async function getEffectiveConfig(configPath?: string): Promise<{
|
|
|
146
151
|
"inference.embedding.dimension",
|
|
147
152
|
"inference.embedding.device",
|
|
148
153
|
"inference.embedding.dtype",
|
|
149
|
-
"inference.embedding.
|
|
154
|
+
"inference.embedding.endpoint",
|
|
150
155
|
"inference.embedding.api_key_env",
|
|
156
|
+
"inference.reranker.adapter",
|
|
157
|
+
"inference.reranker.endpoint",
|
|
158
|
+
"inference.reranker.model",
|
|
159
|
+
"inference.reranker.api_key_env",
|
|
151
160
|
"inference.timeout_ms",
|
|
152
161
|
"server.auth_enabled",
|
|
153
162
|
"server.auth_token_env",
|
|
@@ -178,8 +187,12 @@ export function isEnvMasked(key: string): boolean {
|
|
|
178
187
|
if (key === "inference.models_dir" && (process.env.SKILLMUX_MODELS_DIR || process.env.SKILL_ROUTER_MODELS_DIR)) return true;
|
|
179
188
|
if (key === "inference.embedding.device" && process.env.EMBED_DEVICE) return true;
|
|
180
189
|
if (key === "inference.embedding.dtype" && process.env.EMBED_DTYPE) return true;
|
|
181
|
-
if (key === "inference.embedding.
|
|
190
|
+
if (key === "inference.embedding.endpoint" && (process.env.SKILLMUX_EMBED_ENDPOINT || process.env.EMBED_ENDPOINT)) return true;
|
|
182
191
|
if (key === "inference.embedding.model" && (process.env.SKILLMUX_EMBED_MODEL || process.env.EMBED_MODEL)) return true;
|
|
192
|
+
if (key === "inference.embedding.dimension" && (process.env.SKILLMUX_EMBED_DIMENSION || process.env.EMBED_DIMENSION)) return true;
|
|
193
|
+
if (key === "inference.reranker.adapter" && (process.env.SKILLMUX_RERANK_ADAPTER || process.env.RERANK_ADAPTER)) return true;
|
|
194
|
+
if (key === "inference.reranker.endpoint" && (process.env.SKILLMUX_RERANK_ENDPOINT || process.env.RERANK_ENDPOINT)) return true;
|
|
195
|
+
if (key === "inference.reranker.model" && (process.env.SKILLMUX_RERANK_MODEL || process.env.RERANK_MODEL)) return true;
|
|
183
196
|
if (key === "server.auth_enabled" && process.env.HTTP_AUTH_ENABLED) return true;
|
|
184
197
|
if (key === "server.auth_token_env" && process.env.HTTP_AUTH_TOKEN_ENV) return true;
|
|
185
198
|
if (key === "server.hostname" && process.env.HTTP_HOSTNAME) return true;
|
|
@@ -206,8 +219,12 @@ export function validateDottedKey(key: string): void {
|
|
|
206
219
|
"inference.embedding.dimension",
|
|
207
220
|
"inference.embedding.device",
|
|
208
221
|
"inference.embedding.dtype",
|
|
209
|
-
"inference.embedding.
|
|
222
|
+
"inference.embedding.endpoint",
|
|
210
223
|
"inference.embedding.api_key_env",
|
|
224
|
+
"inference.reranker.adapter",
|
|
225
|
+
"inference.reranker.endpoint",
|
|
226
|
+
"inference.reranker.model",
|
|
227
|
+
"inference.reranker.api_key_env",
|
|
211
228
|
"inference.timeout_ms",
|
|
212
229
|
"server.auth_enabled",
|
|
213
230
|
"server.auth_token_env",
|
package/src/config-watcher.ts
CHANGED
|
@@ -18,6 +18,13 @@ export const LIVE_RELOAD_KEYS = new Set([
|
|
|
18
18
|
"recall.k_lexical",
|
|
19
19
|
"recall.k_vector",
|
|
20
20
|
"thresholds.candidate_limit",
|
|
21
|
+
"inference.embedding.endpoint",
|
|
22
|
+
"inference.embedding.api_key_env",
|
|
23
|
+
"inference.reranker.adapter",
|
|
24
|
+
"inference.reranker.endpoint",
|
|
25
|
+
"inference.reranker.model",
|
|
26
|
+
"inference.reranker.api_key_env",
|
|
27
|
+
"inference.timeout_ms",
|
|
21
28
|
]);
|
|
22
29
|
|
|
23
30
|
// ---------------------------------------------------------------------------
|
package/src/config.ts
CHANGED
|
@@ -48,14 +48,14 @@ const configSchema = z.object({
|
|
|
48
48
|
timeout_ms: z.number().int().min(100),
|
|
49
49
|
embedding: z.object({
|
|
50
50
|
provider: z.literal("openai"),
|
|
51
|
-
|
|
51
|
+
endpoint: z.url(),
|
|
52
52
|
model: z.string().min(1),
|
|
53
53
|
dimension: z.number().int().positive(),
|
|
54
54
|
api_key_env: z.string().min(1).optional(),
|
|
55
55
|
}).strict(),
|
|
56
56
|
reranker: z.object({
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
adapter: z.enum(["jina-v1", "bifrost-v1"]),
|
|
58
|
+
endpoint: z.url(),
|
|
59
59
|
model: z.string().min(1),
|
|
60
60
|
api_key_env: z.string().min(1).optional(),
|
|
61
61
|
}).strict().optional(),
|
|
@@ -133,6 +133,12 @@ export function embeddingFingerprint(config: Config): string {
|
|
|
133
133
|
return `${implementation}:${inference.embedding.model}:${inference.embedding.dimension}`;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
export function rerankerFingerprint(config: Config): string | undefined {
|
|
137
|
+
const inference = config.inference;
|
|
138
|
+
if (inference.mode !== "remote" || !inference.reranker) return undefined;
|
|
139
|
+
return `remote:${inference.reranker.adapter}:${inference.reranker.model}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
export function expandHome(path: string): string {
|
|
137
143
|
return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
|
|
138
144
|
}
|
|
@@ -183,6 +189,30 @@ export function resolveConfigPath(path?: string): string {
|
|
|
183
189
|
|
|
184
190
|
export async function loadConfig(path?: string): Promise<Config> {
|
|
185
191
|
migrateLegacyPaths();
|
|
192
|
+
const removedRerankerEnv = [
|
|
193
|
+
"SKILLMUX_RERANK_BASE_URL",
|
|
194
|
+
"SKILL_ROUTER_RERANK_BASE_URL",
|
|
195
|
+
"RERANK_BASE_URL",
|
|
196
|
+
].find((name) => process.env[name] !== undefined);
|
|
197
|
+
if (removedRerankerEnv) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${removedRerankerEnv} is no longer supported. Configure ` +
|
|
200
|
+
"inference.reranker.endpoint with the complete request URL and " +
|
|
201
|
+
'inference.reranker.adapter (for example, "jina-v1"). The old client appended /rerank.',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const removedEmbeddingEnv = [
|
|
205
|
+
"SKILLMUX_EMBED_BASE_URL",
|
|
206
|
+
"SKILL_ROUTER_EMBED_BASE_URL",
|
|
207
|
+
"EMBED_BASE_URL",
|
|
208
|
+
].find((name) => process.env[name] !== undefined);
|
|
209
|
+
if (removedEmbeddingEnv) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`${removedEmbeddingEnv} is no longer supported. Configure ` +
|
|
212
|
+
"inference.embedding.endpoint with the complete OpenAI-compatible embeddings request URL. " +
|
|
213
|
+
"The old client appended /v1/embeddings.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
186
216
|
const configPath = resolveConfigPath(path);
|
|
187
217
|
const file = Bun.file(expandHome(configPath));
|
|
188
218
|
|
|
@@ -204,6 +234,27 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
204
234
|
"Legacy inference config is not supported. Move [embedding], [rerank], and remote_timeout_ms under [inference] using config.remote.example.toml.",
|
|
205
235
|
);
|
|
206
236
|
}
|
|
237
|
+
const rawReranker = isPlainObject(parsed.inference)
|
|
238
|
+
? parsed.inference.reranker
|
|
239
|
+
: undefined;
|
|
240
|
+
const rawEmbedding = isPlainObject(parsed.inference)
|
|
241
|
+
? parsed.inference.embedding
|
|
242
|
+
: undefined;
|
|
243
|
+
if (
|
|
244
|
+
isPlainObject(rawReranker) &&
|
|
245
|
+
("provider" in rawReranker || "base_url" in rawReranker)
|
|
246
|
+
) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
"inference.reranker.provider and inference.reranker.base_url are no longer supported. " +
|
|
249
|
+
"Use adapter and the complete endpoint URL instead; the old client appended /rerank.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (isPlainObject(rawEmbedding) && "base_url" in rawEmbedding) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"inference.embedding.base_url is no longer supported. Use inference.embedding.endpoint " +
|
|
255
|
+
"with the complete OpenAI-compatible embeddings request URL; the old client appended /v1/embeddings.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
207
258
|
if (isPlainObject(parsed.inference) && parsed.inference.mode === "remote") {
|
|
208
259
|
if (!isPlainObject(parsed.inference.embedding)) {
|
|
209
260
|
throw new Error("Remote inference requires an inference.embedding section.");
|
|
@@ -258,46 +309,76 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
258
309
|
if (merged.inference.embedding?.provider !== "openai") {
|
|
259
310
|
throw new Error('Remote inference.embedding.provider must be "openai".');
|
|
260
311
|
}
|
|
261
|
-
if (merged.inference.reranker && merged.inference.reranker.provider !== "infinity") {
|
|
262
|
-
throw new Error('Remote inference.reranker.provider must be "infinity".');
|
|
263
|
-
}
|
|
264
312
|
if (!Number.isInteger(merged.inference.timeout_ms) || merged.inference.timeout_ms < 100) {
|
|
265
313
|
throw new Error("Remote inference.timeout_ms must be an integer of at least 100.");
|
|
266
314
|
}
|
|
267
|
-
if (!merged.inference.embedding?.
|
|
268
|
-
throw new Error("Remote inference requires inference.embedding
|
|
315
|
+
if (!merged.inference.embedding?.endpoint || !merged.inference.embedding.model || !merged.inference.embedding.dimension) {
|
|
316
|
+
throw new Error("Remote inference requires inference.embedding endpoint, model, and dimension.");
|
|
269
317
|
}
|
|
270
|
-
if (merged.inference.reranker && (!merged.inference.reranker.
|
|
271
|
-
throw new Error("Configured inference.reranker requires
|
|
318
|
+
if (merged.inference.reranker && (!merged.inference.reranker.endpoint || !merged.inference.reranker.model)) {
|
|
319
|
+
throw new Error("Configured inference.reranker requires adapter, endpoint, and model.");
|
|
272
320
|
}
|
|
273
321
|
if (merged.inference.reranker && !merged.inference.thresholds) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const url = new URL(value);
|
|
282
|
-
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
|
|
283
|
-
} catch {
|
|
284
|
-
throw new Error(`${name} must be an HTTP(S) URL.`);
|
|
322
|
+
const warningKey = "inference.reranker.without-thresholds";
|
|
323
|
+
if (!warnedEnv.has(warningKey)) {
|
|
324
|
+
warnedEnv.add(warningKey);
|
|
325
|
+
console.error(
|
|
326
|
+
"skillmux: configured reranker has no calibrated inference.thresholds; " +
|
|
327
|
+
"routing will remain ambiguous until you run `skillmux calibrate run`.",
|
|
328
|
+
);
|
|
285
329
|
}
|
|
286
330
|
}
|
|
287
|
-
const
|
|
331
|
+
const embedEndpoint = getEnv("SKILLMUX_EMBED_ENDPOINT", "EMBED_ENDPOINT");
|
|
288
332
|
const embedModel = getEnv("SKILLMUX_EMBED_MODEL", "EMBED_MODEL");
|
|
289
333
|
const embedDimStr = getEnv("SKILLMUX_EMBED_DIMENSION", "EMBED_DIMENSION");
|
|
290
|
-
const
|
|
334
|
+
const rerankEndpoint = getEnv("SKILLMUX_RERANK_ENDPOINT", "RERANK_ENDPOINT");
|
|
335
|
+
const rerankAdapter = getEnv("SKILLMUX_RERANK_ADAPTER", "RERANK_ADAPTER");
|
|
291
336
|
const rerankModel = getEnv("SKILLMUX_RERANK_MODEL", "RERANK_MODEL");
|
|
292
|
-
if (
|
|
337
|
+
if (embedEndpoint) merged.inference.embedding.endpoint = embedEndpoint;
|
|
293
338
|
if (embedModel) merged.inference.embedding.model = embedModel;
|
|
294
|
-
if (
|
|
339
|
+
if (rerankEndpoint && merged.inference.reranker) merged.inference.reranker.endpoint = rerankEndpoint;
|
|
340
|
+
if (rerankAdapter && merged.inference.reranker) {
|
|
341
|
+
merged.inference.reranker.adapter = rerankAdapter as "jina-v1" | "bifrost-v1";
|
|
342
|
+
}
|
|
295
343
|
if (rerankModel && merged.inference.reranker) merged.inference.reranker.model = rerankModel;
|
|
296
344
|
if (embedDimStr) {
|
|
297
345
|
const dimension = Number(embedDimStr);
|
|
298
346
|
if (!Number.isInteger(dimension) || dimension < 1) throw new Error(`Invalid embedding dimension: ${embedDimStr}`);
|
|
299
347
|
merged.inference.embedding.dimension = dimension;
|
|
300
348
|
}
|
|
349
|
+
for (const [name, value, exactEndpoint] of [
|
|
350
|
+
["inference.embedding.endpoint", merged.inference.embedding.endpoint, true],
|
|
351
|
+
...(merged.inference.reranker
|
|
352
|
+
? [["inference.reranker.endpoint", merged.inference.reranker.endpoint, true] as const]
|
|
353
|
+
: []),
|
|
354
|
+
] as const) {
|
|
355
|
+
try {
|
|
356
|
+
const url = new URL(value);
|
|
357
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
358
|
+
if (exactEndpoint && (url.username || url.password || url.hash)) throw new Error();
|
|
359
|
+
} catch {
|
|
360
|
+
throw new Error(
|
|
361
|
+
exactEndpoint
|
|
362
|
+
? `${name} must be an absolute HTTP(S) URL without userinfo or a fragment.`
|
|
363
|
+
: `${name} must be an HTTP(S) URL.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const [name, apiKeyEnv] of [
|
|
368
|
+
["inference.embedding.api_key_env", merged.inference.embedding.api_key_env],
|
|
369
|
+
...(merged.inference.reranker
|
|
370
|
+
? [["inference.reranker.api_key_env", merged.inference.reranker.api_key_env] as const]
|
|
371
|
+
: []),
|
|
372
|
+
] as const) {
|
|
373
|
+
if (
|
|
374
|
+
apiKeyEnv !== undefined &&
|
|
375
|
+
(process.env[apiKeyEnv] === undefined || process.env[apiKeyEnv] === "")
|
|
376
|
+
) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`${name} names environment variable "${apiKeyEnv}", but it is unset or empty.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
301
382
|
} else {
|
|
302
383
|
throw new Error(`Invalid inference.mode: ${(merged.inference as { mode?: unknown }).mode}`);
|
|
303
384
|
}
|