diff --git a/src/index.ts b/src/index.ts index c769e99..3439c58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -227,6 +227,10 @@ export async function listModels(options: Omit; + taskClassification?: Record; + cacheDetections?: boolean; + detectionCacheTtlMs?: number; + enableAnalytics?: boolean; +} + +export interface SmartRouterTaskConfig { + keywords?: string[]; + patterns?: Array; + model: string; + confidenceThreshold?: number; +} + +export interface SmartRouterValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validate smart-router configuration before it reaches routing logic. + */ +export function validateSmartRouterOptions( + options: SmartRouterValidationOptions +): SmartRouterValidationResult { + const errors: string[] = []; + + if (!options || typeof options !== "object" || Array.isArray(options)) { + return { + valid: false, + errors: ["Smart router options must be an object"] + }; + } + + validateOptionalModel("defaultModel", options.defaultModel, errors); + validateConfidenceThreshold("confidenceThreshold", options.confidenceThreshold, errors); + validateBoolean("cacheDetections", options.cacheDetections, errors); + validateBoolean("enableAnalytics", options.enableAnalytics, errors); + validateCacheTtl(options.detectionCacheTtlMs, errors); + validateModelOverrides(options.modelOverrides, errors); + validateTaskClassification(options.taskClassification, errors); + + return { + valid: errors.length === 0, + errors + }; +} + +/** + * Throw a single helpful error when smart-router configuration is invalid. + */ +export function assertValidSmartRouterOptions(options: SmartRouterValidationOptions): void { + const result = validateSmartRouterOptions(options); + if (!result.valid) { + throw new Error(`TokenFirewall Smart Router: ${result.errors.join("; ")}`); + } +} + +function validateOptionalModel( + field: string, + value: string | undefined, + errors: string[] +): void { + if (value !== undefined && !isNonEmptyString(value)) { + errors.push(`${field} must be a non-empty string when provided`); + } +} + +function validateConfidenceThreshold( + field: string, + value: number | undefined, + errors: string[] +): void { + if (value === undefined) { + return; + } + + if (typeof value !== "number" || Number.isNaN(value) || value < 0 || value > 1) { + errors.push(`${field} must be a number between 0 and 1`); + } +} + +function validateBoolean(field: string, value: boolean | undefined, errors: string[]): void { + if (value !== undefined && typeof value !== "boolean") { + errors.push(`${field} must be a boolean when provided`); + } +} + +function validateCacheTtl(value: number | undefined, errors: string[]): void { + if (value === undefined) { + return; + } + + if (typeof value !== "number" || Number.isNaN(value) || value <= 0) { + errors.push("detectionCacheTtlMs must be a positive number when provided"); + } +} + +function validateModelOverrides( + overrides: Record | undefined, + errors: string[] +): void { + if (overrides === undefined) { + return; + } + + if (!isPlainObject(overrides)) { + errors.push("modelOverrides must be an object mapping task types to model names"); + return; + } + + for (const [taskType, model] of Object.entries(overrides)) { + if (!isNonEmptyString(taskType)) { + errors.push("modelOverrides cannot contain an empty task type"); + } + if (!isNonEmptyString(model)) { + errors.push(`modelOverrides.${taskType} must be a non-empty model name`); + } + } +} + +function validateTaskClassification( + taskClassification: Record | undefined, + errors: string[] +): void { + if (taskClassification === undefined) { + return; + } + + if (!isPlainObject(taskClassification)) { + errors.push("taskClassification must be an object keyed by task type"); + return; + } + + for (const [taskType, config] of Object.entries(taskClassification)) { + if (!isNonEmptyString(taskType)) { + errors.push("taskClassification cannot contain an empty task type"); + } + validateTaskConfig(taskType, config, errors); + } +} + +function validateTaskConfig( + taskType: string, + config: SmartRouterTaskConfig, + errors: string[] +): void { + if (!isPlainObject(config)) { + errors.push(`taskClassification.${taskType} must be an object`); + return; + } + + if (!isNonEmptyString(config.model)) { + errors.push(`taskClassification.${taskType}.model must be a non-empty string`); + } + + validateConfidenceThreshold( + `taskClassification.${taskType}.confidenceThreshold`, + config.confidenceThreshold, + errors + ); + + if (config.keywords !== undefined && !isStringArray(config.keywords)) { + errors.push(`taskClassification.${taskType}.keywords must be an array of strings`); + } + + if (config.patterns !== undefined && !isPatternArray(config.patterns)) { + errors.push(`taskClassification.${taskType}.patterns must contain only strings or RegExp values`); + } +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(isNonEmptyString); +} + +function isPatternArray(value: unknown): value is Array { + return Array.isArray(value) && value.every((pattern) => + isNonEmptyString(pattern) || pattern instanceof RegExp + ); +} diff --git a/tests/smart-router-validator.test.js b/tests/smart-router-validator.test.js new file mode 100644 index 0000000..09c0b5a --- /dev/null +++ b/tests/smart-router-validator.test.js @@ -0,0 +1,83 @@ +/** + * Smart router configuration validator tests. + * + * Run: node tests/smart-router-validator.test.js + */ + +const { + assertValidSmartRouterOptions, + validateSmartRouterOptions, +} = require("../dist/index.js"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +try { + const valid = validateSmartRouterOptions({ + defaultModel: "gpt-4o-mini", + confidenceThreshold: 0.7, + cacheDetections: true, + detectionCacheTtlMs: 60_000, + enableAnalytics: false, + modelOverrides: { + code_generation: "claude-3-5-sonnet-20241022", + }, + taskClassification: { + support_triage: { + keywords: ["support", "ticket"], + patterns: [/urgent/i, "escalate"], + model: "gpt-4o-mini", + confidenceThreshold: 0.55, + }, + }, + }); + + assert(valid.valid, "valid smart router config should pass"); + assert(valid.errors.length === 0, "valid config should not return errors"); + + const invalid = validateSmartRouterOptions({ + defaultModel: "", + confidenceThreshold: 1.2, + cacheDetections: "yes", + detectionCacheTtlMs: 0, + modelOverrides: { + code_generation: "", + }, + taskClassification: { + "": { + keywords: ["ok"], + patterns: [42], + model: "", + confidenceThreshold: -0.1, + }, + }, + }); + + assert(!invalid.valid, "invalid smart router config should fail"); + assert(invalid.errors.some((error) => error.includes("defaultModel")), "should flag defaultModel"); + assert(invalid.errors.some((error) => error.includes("confidenceThreshold")), "should flag threshold"); + assert(invalid.errors.some((error) => error.includes("cacheDetections")), "should flag boolean fields"); + assert(invalid.errors.some((error) => error.includes("detectionCacheTtlMs")), "should flag cache TTL"); + assert(invalid.errors.some((error) => error.includes("modelOverrides.code_generation")), "should flag model override values"); + assert(invalid.errors.some((error) => error.includes("taskClassification cannot contain an empty task type")), "should flag empty task type"); + assert(invalid.errors.some((error) => error.includes(".model")), "should flag task model"); + assert(invalid.errors.some((error) => error.includes(".patterns")), "should flag invalid patterns"); + + let threw = false; + try { + assertValidSmartRouterOptions({ + confidenceThreshold: Number.NaN, + }); + } catch (error) { + threw = error.message.includes("TokenFirewall Smart Router:"); + } + assert(threw, "assertValidSmartRouterOptions should throw a helpful error"); + + console.log("Smart router validator tests passed."); +} catch (error) { + console.error("Smart router validator tests failed:", error.message); + process.exit(1); +}