llm-checker 3.2.1 → 3.2.3

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.
@@ -0,0 +1,497 @@
1
+ const NOOP_POLICY = {
2
+ version: 1,
3
+ org: 'default',
4
+ mode: 'audit',
5
+ rules: {}
6
+ };
7
+
8
+ function isPlainObject(value) {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ function asArray(value) {
13
+ return Array.isArray(value) ? value : [];
14
+ }
15
+
16
+ function toLowerString(value) {
17
+ if (value === undefined || value === null) return null;
18
+ const normalized = String(value).trim();
19
+ return normalized.length > 0 ? normalized.toLowerCase() : null;
20
+ }
21
+
22
+ function normalizePattern(pattern) {
23
+ return typeof pattern === 'string' ? pattern.trim() : '';
24
+ }
25
+
26
+ class PolicyEngine {
27
+ constructor(policy = null) {
28
+ this.policy = isPlainObject(policy) ? policy : NOOP_POLICY;
29
+ this.patternCache = new Map();
30
+ }
31
+
32
+ getMode() {
33
+ return this.policy.mode || 'audit';
34
+ }
35
+
36
+ hasActiveRules() {
37
+ const rules = this.policy.rules || {};
38
+
39
+ return Boolean(
40
+ (isPlainObject(rules.models) && Object.keys(rules.models).length > 0) ||
41
+ (isPlainObject(rules.runtime) && Object.keys(rules.runtime).length > 0) ||
42
+ (isPlainObject(rules.compliance) && Object.keys(rules.compliance).length > 0)
43
+ );
44
+ }
45
+
46
+ evaluateModel(model, context = {}) {
47
+ const target = isPlainObject(model) ? model : {};
48
+ const violations = [];
49
+ const rules = this.policy.rules || {};
50
+
51
+ this.evaluateModelRules(target, rules.models || {}, violations);
52
+ this.evaluateRuntimeRules(target, context, rules.runtime || {}, violations);
53
+ this.evaluateComplianceRules(target, rules.compliance || {}, violations);
54
+
55
+ return {
56
+ pass: violations.length === 0,
57
+ mode: this.getMode(),
58
+ violationCount: violations.length,
59
+ violations,
60
+ rationale: this.buildRationale(violations)
61
+ };
62
+ }
63
+
64
+ evaluateModels(models, context = {}) {
65
+ return asArray(models).map((model) => {
66
+ const policyResult = this.evaluateModel(model, context);
67
+ return { ...model, policyResult };
68
+ });
69
+ }
70
+
71
+ evaluateScoredVariants(scoredVariants, context = {}) {
72
+ return asArray(scoredVariants).map((item) => {
73
+ if (isPlainObject(item) && isPlainObject(item.variant)) {
74
+ const policyResult = this.evaluateModel(item.variant, context);
75
+ return {
76
+ ...item,
77
+ policyResult,
78
+ variant: {
79
+ ...item.variant,
80
+ policyResult
81
+ }
82
+ };
83
+ }
84
+
85
+ const policyResult = this.evaluateModel(item, context);
86
+ return {
87
+ ...item,
88
+ policyResult
89
+ };
90
+ });
91
+ }
92
+
93
+ evaluateModelRules(model, modelRules, violations) {
94
+ if (!isPlainObject(modelRules)) return;
95
+
96
+ const modelTargets = this.getModelTargets(model);
97
+ const modelIdentifier = this.getModelIdentifier(model);
98
+
99
+ const denyPatterns = asArray(modelRules.deny).map(normalizePattern).filter(Boolean);
100
+ const allowPatterns = asArray(modelRules.allow).map(normalizePattern).filter(Boolean);
101
+
102
+ const denyMatch = denyPatterns.find((pattern) => this.matchesAnyPattern(pattern, modelTargets));
103
+ if (denyMatch) {
104
+ this.pushViolation(
105
+ violations,
106
+ 'MODEL_DENIED',
107
+ 'rules.models.deny',
108
+ `Model is denied by pattern "${denyMatch}".`,
109
+ denyMatch,
110
+ modelIdentifier
111
+ );
112
+ }
113
+
114
+ if (allowPatterns.length > 0) {
115
+ const allowMatch = allowPatterns.find((pattern) => this.matchesAnyPattern(pattern, modelTargets));
116
+ if (!allowMatch) {
117
+ this.pushViolation(
118
+ violations,
119
+ 'MODEL_NOT_ALLOWED',
120
+ 'rules.models.allow',
121
+ 'Model did not match any allow pattern.',
122
+ allowPatterns,
123
+ modelIdentifier
124
+ );
125
+ }
126
+ }
127
+
128
+ if (typeof modelRules.max_size_gb === 'number') {
129
+ const sizeGB = this.getModelSizeGB(model);
130
+ if (sizeGB === null) {
131
+ this.pushViolation(
132
+ violations,
133
+ 'MODEL_SIZE_UNKNOWN',
134
+ 'rules.models.max_size_gb',
135
+ 'Model size is missing and cannot be evaluated.',
136
+ `<= ${modelRules.max_size_gb}`,
137
+ null
138
+ );
139
+ } else if (sizeGB > modelRules.max_size_gb) {
140
+ this.pushViolation(
141
+ violations,
142
+ 'MODEL_TOO_LARGE',
143
+ 'rules.models.max_size_gb',
144
+ 'Model exceeds maximum allowed size.',
145
+ `<= ${modelRules.max_size_gb}`,
146
+ sizeGB
147
+ );
148
+ }
149
+ }
150
+
151
+ if (typeof modelRules.max_params_b === 'number') {
152
+ const paramsB = this.getModelParamsB(model);
153
+ if (paramsB === null) {
154
+ this.pushViolation(
155
+ violations,
156
+ 'MODEL_PARAMS_UNKNOWN',
157
+ 'rules.models.max_params_b',
158
+ 'Model parameter count is missing and cannot be evaluated.',
159
+ `<= ${modelRules.max_params_b}`,
160
+ null
161
+ );
162
+ } else if (paramsB > modelRules.max_params_b) {
163
+ this.pushViolation(
164
+ violations,
165
+ 'MODEL_TOO_MANY_PARAMS',
166
+ 'rules.models.max_params_b',
167
+ 'Model exceeds maximum allowed parameters.',
168
+ `<= ${modelRules.max_params_b}`,
169
+ paramsB
170
+ );
171
+ }
172
+ }
173
+
174
+ const allowedQuants = asArray(modelRules.allowed_quantizations)
175
+ .map((quant) => toLowerString(quant))
176
+ .filter(Boolean);
177
+
178
+ if (allowedQuants.length > 0) {
179
+ const quant = this.getModelQuantization(model);
180
+ if (!quant) {
181
+ this.pushViolation(
182
+ violations,
183
+ 'QUANTIZATION_UNKNOWN',
184
+ 'rules.models.allowed_quantizations',
185
+ 'Model quantization is missing and cannot be evaluated.',
186
+ allowedQuants,
187
+ null
188
+ );
189
+ } else if (!allowedQuants.includes(quant)) {
190
+ this.pushViolation(
191
+ violations,
192
+ 'QUANTIZATION_NOT_ALLOWED',
193
+ 'rules.models.allowed_quantizations',
194
+ 'Model quantization is not in allowlist.',
195
+ allowedQuants,
196
+ quant
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ evaluateRuntimeRules(model, context, runtimeRules, violations) {
203
+ if (!isPlainObject(runtimeRules)) return;
204
+
205
+ const backend = this.resolveBackend(model, context);
206
+ const requiredBackends = asArray(runtimeRules.required_backends)
207
+ .map((item) => toLowerString(item))
208
+ .filter(Boolean);
209
+
210
+ if (requiredBackends.length > 0) {
211
+ if (!backend) {
212
+ this.pushViolation(
213
+ violations,
214
+ 'BACKEND_UNKNOWN',
215
+ 'rules.runtime.required_backends',
216
+ 'Runtime backend is missing and cannot be evaluated.',
217
+ requiredBackends,
218
+ null
219
+ );
220
+ } else if (!requiredBackends.includes(backend)) {
221
+ this.pushViolation(
222
+ violations,
223
+ 'BACKEND_NOT_ALLOWED',
224
+ 'rules.runtime.required_backends',
225
+ 'Runtime backend is not in the required backend list.',
226
+ requiredBackends,
227
+ backend
228
+ );
229
+ }
230
+ }
231
+
232
+ if (typeof runtimeRules.min_ram_gb === 'number') {
233
+ const availableRam = this.resolveSystemRamGB(context);
234
+ if (availableRam === null) {
235
+ this.pushViolation(
236
+ violations,
237
+ 'RAM_UNKNOWN',
238
+ 'rules.runtime.min_ram_gb',
239
+ 'System RAM is missing and cannot be evaluated.',
240
+ `>= ${runtimeRules.min_ram_gb}`,
241
+ null
242
+ );
243
+ } else if (availableRam < runtimeRules.min_ram_gb) {
244
+ this.pushViolation(
245
+ violations,
246
+ 'INSUFFICIENT_RAM',
247
+ 'rules.runtime.min_ram_gb',
248
+ 'System RAM is below policy minimum.',
249
+ `>= ${runtimeRules.min_ram_gb}`,
250
+ availableRam
251
+ );
252
+ }
253
+ }
254
+
255
+ if (runtimeRules.local_only === true) {
256
+ const isLocal = this.resolveIsLocal(model, context);
257
+ if (!isLocal) {
258
+ this.pushViolation(
259
+ violations,
260
+ 'MODEL_NOT_LOCAL',
261
+ 'rules.runtime.local_only',
262
+ 'Policy requires local execution, but model source is non-local.',
263
+ true,
264
+ isLocal
265
+ );
266
+ }
267
+ }
268
+ }
269
+
270
+ evaluateComplianceRules(model, complianceRules, violations) {
271
+ if (!isPlainObject(complianceRules)) return;
272
+
273
+ const approvedLicenses = asArray(complianceRules.approved_licenses)
274
+ .map((license) => toLowerString(license))
275
+ .filter(Boolean);
276
+
277
+ if (approvedLicenses.length === 0) return;
278
+
279
+ const license = this.getModelLicense(model);
280
+ if (!license) {
281
+ this.pushViolation(
282
+ violations,
283
+ 'LICENSE_MISSING',
284
+ 'rules.compliance.approved_licenses',
285
+ 'Model license metadata is missing.',
286
+ approvedLicenses,
287
+ null
288
+ );
289
+ return;
290
+ }
291
+
292
+ if (!approvedLicenses.includes(license)) {
293
+ this.pushViolation(
294
+ violations,
295
+ 'LICENSE_NOT_APPROVED',
296
+ 'rules.compliance.approved_licenses',
297
+ 'Model license is not in approved allowlist.',
298
+ approvedLicenses,
299
+ license
300
+ );
301
+ }
302
+ }
303
+
304
+ buildRationale(violations) {
305
+ if (!Array.isArray(violations) || violations.length === 0) {
306
+ return ['Policy evaluation passed with zero violations.'];
307
+ }
308
+
309
+ return violations.map((violation) => `${violation.code}: ${violation.message}`);
310
+ }
311
+
312
+ pushViolation(violations, code, rulePath, message, expected, actual) {
313
+ violations.push({
314
+ code,
315
+ path: rulePath,
316
+ message,
317
+ expected,
318
+ actual
319
+ });
320
+ }
321
+
322
+ getModelTargets(model) {
323
+ const values = [
324
+ this.getModelIdentifier(model),
325
+ toLowerString(model.model_id),
326
+ toLowerString(model.modelId),
327
+ toLowerString(model.tag),
328
+ toLowerString(model.name),
329
+ toLowerString(model.model_name),
330
+ toLowerString(model.family)
331
+ ].filter(Boolean);
332
+
333
+ return Array.from(new Set(values));
334
+ }
335
+
336
+ getModelIdentifier(model) {
337
+ const identifier =
338
+ toLowerString(model.model_identifier) ||
339
+ toLowerString(model.modelIdentifier) ||
340
+ toLowerString(model.tag) ||
341
+ toLowerString(model.name) ||
342
+ toLowerString(model.model_name);
343
+
344
+ if (identifier && identifier.includes(':')) return identifier;
345
+
346
+ const modelId = toLowerString(model.model_id) || toLowerString(model.modelId);
347
+ if (modelId && identifier) {
348
+ return `${modelId}:${identifier}`;
349
+ }
350
+
351
+ return modelId || identifier || 'unknown:model';
352
+ }
353
+
354
+ getModelSizeGB(model) {
355
+ const explicit = this.toNumber(model.size_gb) ?? this.toNumber(model.sizeGB);
356
+ if (explicit !== null) return explicit;
357
+
358
+ const rawSize = toLowerString(model.size);
359
+ if (rawSize) {
360
+ const mbMatch = rawSize.match(/([0-9]+(?:\.[0-9]+)?)\s*mb/);
361
+ if (mbMatch) return parseFloat(mbMatch[1]) / 1024;
362
+
363
+ const gbMatch = rawSize.match(/([0-9]+(?:\.[0-9]+)?)\s*gb/);
364
+ if (gbMatch) return parseFloat(gbMatch[1]);
365
+ }
366
+
367
+ const paramsB = this.getModelParamsB(model);
368
+ if (paramsB !== null) {
369
+ const quant = this.getModelQuantization(model) || 'q4_k_m';
370
+ if (quant.includes('q8') || quant.includes('f16') || quant.includes('fp16')) {
371
+ return paramsB;
372
+ }
373
+ if (quant.includes('q6')) {
374
+ return paramsB * 0.75;
375
+ }
376
+ if (quant.includes('q5')) {
377
+ return paramsB * 0.6;
378
+ }
379
+ if (quant.includes('q3')) {
380
+ return paramsB * 0.4;
381
+ }
382
+ if (quant.includes('q2')) {
383
+ return paramsB * 0.3;
384
+ }
385
+ return paramsB * 0.5;
386
+ }
387
+
388
+ return null;
389
+ }
390
+
391
+ getModelParamsB(model) {
392
+ const explicit = this.toNumber(model.params_b) ?? this.toNumber(model.paramsB);
393
+ if (explicit !== null) return explicit;
394
+
395
+ const identifier = this.getModelIdentifier(model);
396
+ const match = identifier.match(/([0-9]+(?:\.[0-9]+)?)b\b/i);
397
+ if (match) return parseFloat(match[1]);
398
+
399
+ return null;
400
+ }
401
+
402
+ getModelQuantization(model) {
403
+ const raw =
404
+ toLowerString(model.quant) ||
405
+ toLowerString(model.quantization) ||
406
+ toLowerString(model.quant_type);
407
+
408
+ return raw;
409
+ }
410
+
411
+ getModelLicense(model) {
412
+ const raw =
413
+ toLowerString(model.license) ||
414
+ toLowerString(model.license_id) ||
415
+ toLowerString(model.licenseId);
416
+
417
+ return raw;
418
+ }
419
+
420
+ resolveBackend(model, context) {
421
+ const fromContext = toLowerString(context.backend) || toLowerString(context.runtimeBackend);
422
+ if (fromContext) return fromContext;
423
+
424
+ const fromHardware =
425
+ toLowerString(context?.hardware?.summary?.bestBackend) ||
426
+ toLowerString(context?.hardware?.backend);
427
+ if (fromHardware) return fromHardware;
428
+
429
+ return toLowerString(model.backend);
430
+ }
431
+
432
+ resolveSystemRamGB(context) {
433
+ const direct =
434
+ this.toNumber(context.ramGB) ??
435
+ this.toNumber(context.totalRamGB) ??
436
+ this.toNumber(context?.hardware?.memory?.total) ??
437
+ this.toNumber(context?.hardware?.summary?.systemRAM) ??
438
+ this.toNumber(context?.hardware?.summary?.effectiveMemory);
439
+
440
+ return direct;
441
+ }
442
+
443
+ resolveIsLocal(model, context) {
444
+ if (typeof context.isLocal === 'boolean') {
445
+ return context.isLocal;
446
+ }
447
+
448
+ if (typeof model.is_local === 'boolean') return model.is_local;
449
+ if (typeof model.isLocal === 'boolean') return model.isLocal;
450
+ if (typeof model.local === 'boolean') return model.local;
451
+
452
+ const source = toLowerString(model.source);
453
+ if (source) {
454
+ return source === 'local' || source === 'ollama';
455
+ }
456
+
457
+ const type = toLowerString(model.type);
458
+ if (type) {
459
+ return type !== 'cloud' && type !== 'remote' && type !== 'hosted';
460
+ }
461
+
462
+ return true;
463
+ }
464
+
465
+ toNumber(value) {
466
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
467
+ if (typeof value === 'string') {
468
+ const normalized = value.trim();
469
+ if (!normalized) return null;
470
+ const parsed = Number.parseFloat(normalized);
471
+ if (Number.isFinite(parsed)) return parsed;
472
+ }
473
+ return null;
474
+ }
475
+
476
+ matchesAnyPattern(pattern, targets) {
477
+ if (!pattern || !Array.isArray(targets)) return false;
478
+ const regex = this.getPatternRegex(pattern);
479
+ return targets.some((target) => regex.test(target));
480
+ }
481
+
482
+ getPatternRegex(pattern) {
483
+ if (this.patternCache.has(pattern)) {
484
+ return this.patternCache.get(pattern);
485
+ }
486
+
487
+ const escaped = pattern
488
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
489
+ .replace(/\*/g, '.*');
490
+
491
+ const regex = new RegExp(`^${escaped}$`, 'i');
492
+ this.patternCache.set(pattern, regex);
493
+ return regex;
494
+ }
495
+ }
496
+
497
+ module.exports = PolicyEngine;