diff --git a/e2e-tests/04-cache-test.py b/e2e-tests/04-cache-test.py
index ce76e37702..bdb2349119 100644
--- a/e2e-tests/04-cache-test.py
+++ b/e2e-tests/04-cache-test.py
@@ -16,7 +16,7 @@
# Add parent directory to path to allow importing common test utilities
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from tests.test_base import SemanticRouterTestBase
+from test_base import SemanticRouterTestBase
# Constants
ENVOY_URL = "http://localhost:8801"
diff --git a/src/semantic-router/pkg/config/config.go b/src/semantic-router/pkg/config/config.go
index c72cbce171..edf4fa4567 100644
--- a/src/semantic-router/pkg/config/config.go
+++ b/src/semantic-router/pkg/config/config.go
@@ -10,8 +10,19 @@ import (
"gopkg.in/yaml.v3"
)
+// KeywordRule defines a rule for keyword-based classification.
+type KeywordRule struct {
+ Category string `yaml:"category"`
+ Operator string `yaml:"operator"`
+ Keywords []string `yaml:"keywords"`
+ CaseSensitive bool `yaml:"case_sensitive"`
+}
+
// RouterConfig represents the main configuration for the LLM Router
type RouterConfig struct {
+ // Keyword-based classification rules
+ KeywordRules []KeywordRule `yaml:"keyword_rules,omitempty"`
+
// BERT model configuration for Candle BERT similarity comparison
BertModel struct {
ModelID string `yaml:"model_id"`
diff --git a/src/semantic-router/pkg/utils/classification/benchmark_test.go b/src/semantic-router/pkg/utils/classification/benchmark_test.go
new file mode 100644
index 0000000000..a51a72f6c8
--- /dev/null
+++ b/src/semantic-router/pkg/utils/classification/benchmark_test.go
@@ -0,0 +1,54 @@
+package classification
+
+import (
+ "testing"
+
+ "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config"
+)
+
+func BenchmarkKeywordClassifier(b *testing.B) {
+ rules := []config.KeywordRule{
+ {
+ Category: "test-category-1",
+ Operator: "AND",
+ Keywords: []string{"keyword1", "keyword2"},
+ },
+ {
+ Category: "test-category-2",
+ Operator: "OR",
+ Keywords: []string{"keyword3", "keyword4"},
+ CaseSensitive: true,
+ },
+ {
+ Category: "test-category-3",
+ Operator: "NOR",
+ Keywords: []string{"keyword5", "keyword6"},
+ },
+ }
+
+ classifier := NewKeywordClassifier(rules)
+
+ b.Run("AND match", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, _, _ = classifier.Classify("this text contains keyword1 and keyword2")
+ }
+ })
+
+ b.Run("OR match", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, _, _ = classifier.Classify("this text contains keyword3")
+ }
+ })
+
+ b.Run("NOR match", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, _, _ = classifier.Classify("this text is clean")
+ }
+ })
+
+ b.Run("No match", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _, _, _ = classifier.Classify("this text contains keyword5")
+ }
+ })
+}
diff --git a/src/semantic-router/pkg/utils/classification/classifier.go b/src/semantic-router/pkg/utils/classification/classifier.go
index de7e02ef71..12caed7e99 100644
--- a/src/semantic-router/pkg/utils/classification/classifier.go
+++ b/src/semantic-router/pkg/utils/classification/classifier.go
@@ -204,6 +204,7 @@ type Classifier struct {
jailbreakInference JailbreakInference
piiInitializer PIIInitializer
piiInference PIIInference
+ keywordClassifier *KeywordClassifier
// Dependencies - MCP-based classifiers
mcpCategoryInitializer MCPCategoryInitializer
@@ -247,6 +248,12 @@ func withPII(piiMapping *PIIMapping, piiInitializer PIIInitializer, piiInference
}
}
+func withKeywordClassifier(keywordClassifier *KeywordClassifier) option {
+ return func(c *Classifier) {
+ c.keywordClassifier = keywordClassifier
+ }
+}
+
// initModels initializes the models for the classifier
func initModels(classifier *Classifier) (*Classifier, error) {
// Initialize either in-tree OR MCP-based category classifier
@@ -303,6 +310,11 @@ func NewClassifier(cfg *config.RouterConfig, categoryMapping *CategoryMapping, p
withPII(piiMapping, createPIIInitializer(), createPIIInference()),
}
+ // Add keyword classifier if configured
+ if len(cfg.KeywordRules) > 0 {
+ options = append(options, withKeywordClassifier(NewKeywordClassifier(cfg.KeywordRules)))
+ }
+
// Add in-tree classifier if configured
if cfg.Classifier.CategoryModel.ModelID != "" {
options = append(options, withCategory(categoryMapping, createCategoryInitializer(cfg.Classifier.CategoryModel.UseModernBERT), createCategoryInference(cfg.Classifier.CategoryModel.UseModernBERT)))
@@ -342,6 +354,17 @@ func (c *Classifier) initializeCategoryClassifier() error {
// ClassifyCategory performs category classification on the given text
func (c *Classifier) ClassifyCategory(text string) (string, float64, error) {
+ // Try keyword classifier first
+ if c.keywordClassifier != nil {
+ category, confidence, err := c.keywordClassifier.Classify(text)
+ if err != nil {
+ return "", 0.0, err
+ }
+ if category != "" {
+ return category, confidence, nil
+ }
+ }
+
// Try in-tree first if properly configured
if c.IsCategoryEnabled() && c.categoryInference != nil {
return c.classifyCategoryInTree(text)
diff --git a/src/semantic-router/pkg/utils/classification/keyword_classifier.go b/src/semantic-router/pkg/utils/classification/keyword_classifier.go
new file mode 100644
index 0000000000..f23d512b91
--- /dev/null
+++ b/src/semantic-router/pkg/utils/classification/keyword_classifier.go
@@ -0,0 +1,101 @@
+package classification
+
+import (
+ "strings"
+
+ "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config"
+ "github.com/vllm-project/semantic-router/src/semantic-router/pkg/observability"
+)
+
+// KeywordClassifier implements keyword-based classification logic.
+type KeywordClassifier struct {
+ rules []config.KeywordRule
+}
+
+// NewKeywordClassifier creates a new KeywordClassifier.
+func NewKeywordClassifier(rules []config.KeywordRule) *KeywordClassifier {
+ return &KeywordClassifier{rules: rules}
+}
+
+// Classify performs keyword-based classification on the given text.
+func (c *KeywordClassifier) Classify(text string) (string, float64, error) {
+ for _, rule := range c.rules {
+ if matched, keywords := c.matches(text, rule); matched {
+ if len(keywords) > 0 {
+ observability.Infof(
+ "Keyword-based classification matched category %q with keywords: %v",
+ rule.Category, keywords,
+ )
+ } else {
+ observability.Infof(
+ "Keyword-based classification matched category %q with a NOR rule.",
+ rule.Category,
+ )
+ }
+ return rule.Category, 1.0, nil
+ }
+ }
+ return "", 0.0, nil
+}
+
+// matches checks if the text matches the given keyword rule.
+func (c *KeywordClassifier) matches(text string, rule config.KeywordRule) (bool, []string) {
+ // Default to case-insensitive matching if not specified
+ caseSensitive := rule.CaseSensitive
+ var matchedKeywords []string
+
+ // Prepare text for matching
+ preparedText := text
+ if !caseSensitive {
+ preparedText = strings.ToLower(text)
+ }
+
+ // Check for matches based on the operator
+ switch rule.Operator {
+ case "AND":
+ for _, keyword := range rule.Keywords {
+ preparedKeyword := keyword
+ if !caseSensitive {
+ preparedKeyword = strings.ToLower(keyword)
+ }
+ if !strings.Contains(preparedText, preparedKeyword) {
+ return false, nil
+ }
+ matchedKeywords = append(matchedKeywords, keyword)
+ }
+ return true, matchedKeywords
+
+ case "OR":
+ for _, keyword := range rule.Keywords {
+ preparedKeyword := keyword
+ if !caseSensitive {
+ preparedKeyword = strings.ToLower(keyword)
+ }
+ if strings.Contains(preparedText, preparedKeyword) {
+ // For OR, we can return on the first match.
+ return true, []string{keyword}
+ }
+ }
+ return false, nil
+
+ case "NOR":
+ for _, keyword := range rule.Keywords {
+ preparedKeyword := keyword
+ if !caseSensitive {
+ preparedKeyword = strings.ToLower(keyword)
+ }
+ if strings.Contains(preparedText, preparedKeyword) {
+ return false, nil
+ }
+ }
+ // Return true with an empty slice
+ return true, matchedKeywords
+
+ default:
+ observability.Warnf(
+ "KeywordClassifier: unsupported operator %q in rule for category %q. Returning no match.",
+ rule.Operator, rule.Category,
+ )
+ return false, nil
+ }
+}
diff --git a/src/semantic-router/pkg/utils/classification/keyword_classifier_test.go b/src/semantic-router/pkg/utils/classification/keyword_classifier_test.go
new file mode 100644
index 0000000000..8d39df0084
--- /dev/null
+++ b/src/semantic-router/pkg/utils/classification/keyword_classifier_test.go
@@ -0,0 +1,92 @@
+package classification
+
+import (
+ "testing"
+
+ "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config"
+)
+
+func TestKeywordClassifier(t *testing.T) {
+ rules := []config.KeywordRule{
+ {
+ Category: "test-category-1",
+ Operator: "AND",
+ Keywords: []string{"keyword1", "keyword2"},
+ },
+ {
+ Category: "test-category-2",
+ Operator: "OR",
+ Keywords: []string{"keyword3", "keyword4"},
+ CaseSensitive: true,
+ },
+ {
+ Category: "test-category-3",
+ Operator: "NOR",
+ Keywords: []string{"keyword5", "keyword6"},
+ },
+ }
+
+ tests := []struct {
+ name string
+ text string
+ expected string
+ }{
+ {
+ name: "AND match",
+ text: "this text contains keyword1 and keyword2",
+ expected: "test-category-1",
+ },
+ {
+ name: "AND no match",
+ // This text does not match the AND rule. It also does not contain "keyword5" or "keyword6",
+ // so the NOR rule will match as a fallback.
+ text: "this text contains keyword1 but not the other",
+ expected: "test-category-3",
+ },
+ {
+ name: "OR match",
+ text: "this text contains keyword3",
+ expected: "test-category-2",
+ },
+ {
+ name: "OR no match",
+ // This text does not match the OR rule. It also does not contain "keyword5" or "keyword6",
+ // so the NOR rule will match as a fallback.
+ text: "this text contains nothing of interest",
+ expected: "test-category-3",
+ },
+ {
+ name: "NOR match",
+ text: "this text is clean",
+ expected: "test-category-3",
+ },
+ {
+ name: "NOR no match",
+ // This text contains "keyword5", so the NOR rule will NOT match.
+ // Since no other rules match, the result should be empty.
+ text: "this text contains keyword5",
+ expected: "",
+ },
+ {
+ name: "Case sensitive no match",
+ // This text does not match the case-sensitive OR rule. It also does not contain "keyword5" or "keyword6",
+ // so the NOR rule will match as a fallback.
+ text: "this text contains KEYWORD3",
+ expected: "test-category-3",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a new classifier for each test to ensure a clean slate
+ classifier := NewKeywordClassifier(rules)
+ category, _, err := classifier.Classify(tt.text)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if category != tt.expected {
+ t.Errorf("expected category %q, but got %q", tt.expected, category)
+ }
+ })
+ }
+}
diff --git a/website/src/components/ScrollToTop/index.tsx b/website/src/components/ScrollToTop/index.tsx
new file mode 100644
index 0000000000..0acd35ec1b
--- /dev/null
+++ b/website/src/components/ScrollToTop/index.tsx
@@ -0,0 +1,58 @@
+import React, { useEffect, useState } from 'react'
+import styles from './styles.module.css'
+
+export default function ScrollToTop(): React.ReactElement {
+ const [isVisible, setIsVisible] = useState(false)
+
+ useEffect(() => {
+ const toggleVisibility = () => {
+ if (window.pageYOffset > 300) {
+ setIsVisible(true)
+ }
+ else {
+ setIsVisible(false)
+ }
+ }
+
+ window.addEventListener('scroll', toggleVisibility)
+
+ return () => {
+ window.removeEventListener('scroll', toggleVisibility)
+ }
+ }, [])
+
+ const scrollToTop = () => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ })
+ }
+
+ return (
+ <>
+ {isVisible && (
+
+ )}
+ >
+ )
+}
diff --git a/website/src/components/ScrollToTop/styles.module.css b/website/src/components/ScrollToTop/styles.module.css
new file mode 100644
index 0000000000..4d407cedee
--- /dev/null
+++ b/website/src/components/ScrollToTop/styles.module.css
@@ -0,0 +1,93 @@
+.scrollToTop {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: linear-gradient(45deg, var(--tech-primary-blue), var(--tech-accent-purple));
+ color: white;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(9, 105, 218, 0.3);
+ transition: all 0.3s ease;
+ z-index: 999;
+ opacity: 0;
+ transform: translateY(20px);
+ animation: fadeInUp 0.3s ease forwards;
+}
+
+@keyframes fadeInUp {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.scrollToTop:hover {
+ transform: translateY(-4px) scale(1.1);
+ box-shadow: 0 8px 20px rgba(9, 105, 218, 0.4);
+}
+
+.scrollToTop:active {
+ transform: translateY(-2px) scale(1.05);
+}
+
+[data-theme='dark'] .scrollToTop {
+ background: linear-gradient(45deg, var(--tech-primary-blue), var(--tech-accent-orange));
+ box-shadow: 0 4px 12px rgba(88, 166, 255, 0.4);
+}
+
+[data-theme='dark'] .scrollToTop:hover {
+ box-shadow: 0 8px 20px rgba(88, 166, 255, 0.5);
+}
+
+@media (max-width: 768px) {
+ .scrollToTop {
+ bottom: 1.5rem;
+ right: 1.5rem;
+ width: 45px;
+ height: 45px;
+ }
+}
+
+.scrollToTop::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 50%;
+ background: inherit;
+ opacity: 0;
+ animation: pulse 2s ease-out infinite;
+}
+
+@keyframes pulse {
+ 0% {
+ transform: scale(1);
+ opacity: 0.5;
+ }
+ 50% {
+ transform: scale(1.1);
+ opacity: 0.3;
+ }
+ 100% {
+ transform: scale(1.3);
+ opacity: 0;
+ }
+}
+
+.scrollToTop svg {
+ position: relative;
+ z-index: 1;
+ transition: transform 0.3s ease;
+}
+
+.scrollToTop:hover svg {
+ transform: translateY(-2px);
+}
diff --git a/website/src/theme/Root.tsx b/website/src/theme/Root.tsx
new file mode 100644
index 0000000000..24b2133da4
--- /dev/null
+++ b/website/src/theme/Root.tsx
@@ -0,0 +1,12 @@
+import React from 'react'
+import Root from '@theme-original/Root'
+import ScrollToTop from '../components/ScrollToTop'
+
+export default function RootWrapper(props: any): React.ReactElement {
+ return (
+ <>
+
+
+ >
+ )
+}