Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ export async function listModels(options: Omit<ListModelsOptions, 'budgetManager

// Keep the original export for backward compatibility
export { listAvailableModels };
export {
assertValidSmartRouterOptions,
validateSmartRouterOptions
} from "./router/smartRouterValidator";

// Export types for TypeScript users
export type {
Expand All @@ -252,6 +256,12 @@ export type {
ApiKeyConfig
} from "./router/types";

export type {
SmartRouterTaskConfig,
SmartRouterValidationOptions,
SmartRouterValidationResult
} from "./router/smartRouterValidator";

/**
* Model configuration for bulk registration
*/
Expand Down
191 changes: 191 additions & 0 deletions src/router/smartRouterValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
export interface SmartRouterValidationOptions {
defaultModel?: string;
confidenceThreshold?: number;
modelOverrides?: Record<string, string>;
taskClassification?: Record<string, SmartRouterTaskConfig>;
cacheDetections?: boolean;
detectionCacheTtlMs?: number;
enableAnalytics?: boolean;
}

export interface SmartRouterTaskConfig {
keywords?: string[];
patterns?: Array<string | RegExp>;
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<string, string> | 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<string, SmartRouterTaskConfig> | 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<string, unknown> {
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<string | RegExp> {
return Array.isArray(value) && value.every((pattern) =>
isNonEmptyString(pattern) || pattern instanceof RegExp
);
}
83 changes: 83 additions & 0 deletions tests/smart-router-validator.test.js
Original file line number Diff line number Diff line change
@@ -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);
}