@klhapp/skillmux 1.0.1 → 1.2.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/src/cli.ts CHANGED
@@ -399,12 +399,54 @@ 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
- if (args[i] === "--dataset") datasetPath = args[++i];
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 res = await adapter.calibrateRun({ datasetPath });
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
- renderTargetBanner(ctx.target);
449
+ renderCalibrationTarget(ctx.target);
408
450
  console.log(`Calibration run complete.`);
409
451
  if (res.result) console.log(JSON.stringify(res.result, null, 2));
410
452
  });
@@ -414,7 +456,7 @@ async function handleCalibrateCommand(
414
456
  if (sub === "list") {
415
457
  const res = await adapter.calibrateList();
416
458
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
417
- renderTargetBanner(ctx.target);
459
+ renderCalibrationTarget(ctx.target);
418
460
  renderTable(
419
461
  [
420
462
  { key: "run_id", header: "RUN_ID" },
@@ -432,7 +474,7 @@ async function handleCalibrateCommand(
432
474
  if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
433
475
  const res = await adapter.calibrateShow(runId);
434
476
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
435
- renderTargetBanner(ctx.target);
477
+ renderCalibrationTarget(ctx.target);
436
478
  console.log(JSON.stringify(res, null, 2));
437
479
  });
438
480
  return;
@@ -443,7 +485,7 @@ async function handleCalibrateCommand(
443
485
  if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
444
486
  const res = await adapter.calibrateApply(runId);
445
487
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
446
- renderTargetBanner(ctx.target);
488
+ renderCalibrationTarget(ctx.target);
447
489
  console.log(`Applied calibration run "${runId}"`);
448
490
  });
449
491
  return;
@@ -459,6 +501,14 @@ async function handleCalibrateCommand(
459
501
  );
460
502
  }
461
503
 
504
+ function renderCalibrationTarget(target: ResolvedTarget): void {
505
+ if (target.type === "local") {
506
+ console.log("Target: local");
507
+ } else {
508
+ console.log(`Target: remote (${target.name} -> ${target.server})`);
509
+ }
510
+ }
511
+
462
512
  async function handleCompletionsCommand(shell: string) {
463
513
  if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
464
514
  throw new Error("usage: skillmux completions <bash|zsh|fish>");
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
- interface EmbeddingResponse {
6
- data: { index: number; embedding: number[] }[];
7
- }
5
+ export type RemoteErrorKind = "configuration" | "availability" | "protocol";
8
6
 
9
- interface RerankResponse {
10
- results: { index: number; relevance_score: number }[];
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 (dim === undefined || output.dims.length !== 2 || output.dims[0] !== texts.length) {
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 result: Float32Array[] = [];
63
- for (let i = 0; i < texts.length; i++) {
64
- const row = output.slice(i, null).tolist();
65
- if (!Array.isArray(row) || row.some((value) => typeof value !== "number")) {
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
- const apiKey = embedding.api_key_env ? process.env[embedding.api_key_env] : undefined;
75
- const cleanBase = embedding.base_url.replace(/\/$/, "");
76
- const embedPath = cleanBase.endsWith("/v1") ? "/embeddings" : "/v1/embeddings";
77
- const response = await fetch(`${cleanBase}${embedPath}`, {
78
- method: "POST",
79
- headers: {
80
- "content-type": "application/json",
81
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
82
- },
83
- body: JSON.stringify({ model: embedding.model, input: texts }),
84
- signal: AbortSignal.timeout(config.inference.timeout_ms),
85
- });
86
- if (!response.ok) throw new Error(`embeddings endpoint returned ${response.status}`);
87
- const parsed = (await response.json()) as EmbeddingResponse;
88
- const byIndex = [...parsed.data].sort((a, b) => a.index - b.index);
89
- return byIndex.map((d) => Float32Array.from(d.embedding));
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
- const apiKey = reranker.api_key_env ? process.env[reranker.api_key_env] : undefined;
98
- const response = await fetch(`${reranker.base_url.replace(/\/$/, "")}/rerank`, {
99
- method: "POST",
100
- headers: { "content-type": "application/json", ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) },
101
- body: JSON.stringify({
102
- model: reranker.model,
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;
@@ -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.model",
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.base_url",
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.base_url" && (process.env.SKILLMUX_EMBED_BASE_URL || process.env.EMBED_BASE_URL)) return true;
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.base_url",
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",
@@ -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
  // ---------------------------------------------------------------------------