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
9 changes: 9 additions & 0 deletions config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ runtime dependency; they do not define routing behavior by themselves.
name an entry in `global.model_catalog.external[]` with
`model_role: classification`. The shared backend contract uses
`protocol`, `contract`, `model`, and optional `deadline_ms`.
- Complexity attaches the same block at
`global.model_catalog.modules.complexity.backend`, beside `prototype_scoring`
rather than on a rule, so it survives the per-recipe replacement of
`routing.signals`. It reads two contracts and therefore requires `contract`
to be stated: `score.v1`, where each rule converts the score with its own
`hard_above`/`easy_below` boundaries, or `label_distribution.v1`, where the
winning label is the verdict. `threshold` stays the symmetric shorthand for
the local signed margin, and the `hard`/`easy` candidate lists are unread
once a backend supplies the score.
- External LLM classifiers use `max_response_bytes` on their
`global.model_catalog.external[]` entry. The MCP classifier uses the same key
under `global.model_catalog.modules.classifier.mcp`.
Expand Down
16 changes: 16 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,22 @@ routing:
name: long_context
- type: domain
name: "computer science"
- name: deep_synthesis
# The explicit boundary pair, the asymmetric form of `threshold`: the
# two verdicts do not have to sit the same distance from the middle.
# hard_above with easy_below reads in the direction the local margin
# runs, where a higher score is harder.
hard_above: 0.25
easy_below: -0.05
description: Escalate synthesis-heavy prompts, with a wider medium band than needs_reasoning.
hard:
candidates:
- synthesise these sources into one argument
- reconcile the conflicting requirements
easy:
candidates:
- list the headings
- restate this sentence

modality:
- name: AR
Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ until the selected cases are known to be isolated.
- **multimodal-routing**: image-modality embedding routing.
- **remote-embedding**: OpenAI-compatible remote embedding providers.
- **category-remote-backend**: shared remote category `http_classify` backend.
- **complexity-remote-backend**: shared remote complexity `score.v1` backend, with no local candidates so a verdict can only come from the remote score.
- **llm-d**: llm-d inference-gateway health and router smoke coverage.
- **looper**: deterministic Looper algorithm contracts.
- **istio**: sidecar, mTLS, and tracing behavior.
Expand Down
2 changes: 2 additions & 0 deletions e2e/profiles/all/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
anthropicshim "github.com/vllm-project/semantic-router/e2e/profiles/anthropic-shim"
authzrbac "github.com/vllm-project/semantic-router/e2e/profiles/authz-rbac"
categoryremotebackend "github.com/vllm-project/semantic-router/e2e/profiles/category-remote-backend"
complexityremotebackend "github.com/vllm-project/semantic-router/e2e/profiles/complexity-remote-backend"
dashboard "github.com/vllm-project/semantic-router/e2e/profiles/dashboard"
dynamicconfig "github.com/vllm-project/semantic-router/e2e/profiles/dynamic-config"
dynamo "github.com/vllm-project/semantic-router/e2e/profiles/dynamo"
Expand Down Expand Up @@ -65,6 +66,7 @@ func init() {
)
register("authz-rbac", func() framework.Profile { return authzrbac.NewProfile() }, framework.ProfileCapabilities{})
register("category-remote-backend", func() framework.Profile { return categoryremotebackend.NewProfile() }, framework.ProfileCapabilities{LocalImages: mockVLLMLocalImages})
register("complexity-remote-backend", func() framework.Profile { return complexityremotebackend.NewProfile() }, framework.ProfileCapabilities{LocalImages: mockVLLMLocalImages})
register(
"dashboard",
func() framework.Profile { return dashboard.NewProfile() },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: mock-difficulty-scorer
namespace: default
data:
server.py: |
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# A score.v1 backend: one continuous score per request, in the model's own
# units. The response is the HuggingFace text-classification shape with
# exactly one entry, since score.v1 rejects anything else - with several
# entries, which one is "the" score would be undefined. The label is
# meaningless on this contract.
MARKER = "__COMPLEXITY_SCORE__"
DEFAULT_SCORE = 0.5

def selected_score(text):
"""Read a score pinned by the request.

The test pins it so the routing assertion is attributable to this
server rather than to whatever the text happens to resemble. An
unmarked request returns a constant, so it cannot drift between runs
and make the assertion flaky for reasons unrelated to the router.
"""
marker_at = text.find(MARKER)
if marker_at == -1:
return DEFAULT_SCORE
tail = text[marker_at + len(MARKER):].strip().split()
if not tail:
return DEFAULT_SCORE
try:
return float(tail[0])
except ValueError:
return DEFAULT_SCORE

class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return

def send_json(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self):
if self.path == "/health":
self.send_json(200, {"status": "ok"})
return
self.send_json(404, {"error": "not found"})

def do_POST(self):
if self.path != "/classify":
self.send_json(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
text = json.loads(self.rfile.read(length))["inputs"]
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
self.send_json(400, {"error": "invalid request"})
return
self.send_json(200, [
{"label": "LABEL_0", "score": selected_score(str(text))},
])

ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mock-difficulty-scorer
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: mock-difficulty-scorer
template:
metadata:
labels:
app: mock-difficulty-scorer
spec:
containers:
- name: mock-difficulty-scorer
image: python:3.12-alpine
imagePullPolicy: IfNotPresent
command: ["python3", "/app/server.py"]
ports:
- name: http
containerPort: 8000
volumeMounts:
- name: script
mountPath: /app/server.py
subPath: server.py
readOnly: true
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 2
timeoutSeconds: 2
failureThreshold: 30
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
volumes:
- name: script
configMap:
name: mock-difficulty-scorer
---
apiVersion: v1
kind: Service
metadata:
name: mock-difficulty-scorer
namespace: default
spec:
selector:
app: mock-difficulty-scorer
ports:
- name: http
port: 8000
targetPort: http
protocol: TCP
66 changes: 66 additions & 0 deletions e2e/profiles/complexity-remote-backend/profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package complexityremotebackend provides the e2e profile for #2921's
// complexity backend. It drives the complexity signal through a remote
// score.v1 scorer with no local candidates at all, so a verdict can only have
// come from the remote call.
package complexityremotebackend

import (
"context"

"github.com/vllm-project/semantic-router/e2e/pkg/framework"
"github.com/vllm-project/semantic-router/e2e/pkg/helpers"
gatewaystack "github.com/vllm-project/semantic-router/e2e/pkg/stacks/gateway"

Check failure on line 13 in e2e/profiles/complexity-remote-backend/profile.go

View workflow job for this annotation

GitHub Actions / Core quality / Full pre-commit checks

File is not properly formatted (gci)
_ "github.com/vllm-project/semantic-router/e2e/testcases"
)

const valuesFile = "e2e/profiles/complexity-remote-backend/values.yaml"

var resourceManifests = []string{
"e2e/profiles/complexity-remote-backend/manifests/mock-difficulty-scorer.yaml",
"e2e/profiles/ai-gateway/gateway-resources/backend.yaml",
"deploy/kubernetes/ai-gateway/aigw-resources/gwapi-resources.yaml",
"e2e/profiles/ai-gateway/gateway-resources/responses-route.yaml",
}

// Profile validates the shared remote complexity score.v1 backend in isolation.
type Profile struct {
stack *gatewaystack.Stack
}

func NewProfile() *Profile {
return &Profile{
stack: gatewaystack.New(gatewaystack.Config{
Name: "complexity-remote-backend",
SemanticRouterValuesFile: valuesFile,
ResourceManifests: resourceManifests,
WaitDeployments: []helpers.DeploymentRef{
{Namespace: "default", Name: "mock-difficulty-scorer"},
},
}),
}
}

func (p *Profile) Name() string { return "complexity-remote-backend" }

func (p *Profile) Description() string {
return "Tests the shared remote complexity score.v1 backend end-to-end"
}

func (p *Profile) Setup(ctx context.Context, opts *framework.SetupOptions) error {
return p.stack.Setup(ctx, opts)
}

func (p *Profile) Teardown(ctx context.Context, opts *framework.TeardownOptions) error {
return p.stack.Teardown(ctx, opts)
}

func (p *Profile) GetTestCases() []string {
return []string{
"complexity-backend-routing",
}
}

func (p *Profile) GetServiceConfig() framework.ServiceConfig {
return p.stack.ServiceConfig()
}
120 changes: 120 additions & 0 deletions e2e/profiles/complexity-remote-backend/values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
config:
version: v0.3
listeners: []
providers:
defaults:
# general-expert, not a profile-specific name: the shared
# AIGatewayRoute in deploy/kubernetes/ai-gateway/aigw-resources/
# matches x-ai-eg-model exactly against six names (math-, science-,
# social-, humanities-, law- and general-expert). A model outside that
# list has no route, so every request 404s at the gateway while the
# router itself looks perfectly healthy - which is what makes it easy
# to mistake for a router bug.
model: general-expert
models:
- name: general-expert
provider_model_id: general-expert
backend_refs:
- name: local-vllm
provider: vllm
endpoint: vllm-llama3-8b-instruct.default.svc.cluster.local:8000
weight: 1
routing:
modelCards:
- name: general-expert
signals:
# Two rules over one remote score, differing only in where they draw
# their boundaries. No hard/easy candidates: with a backend the local
# prototype path is unreachable, so a verdict here can only have come
# from the remote scorer. That is what makes the assertion attributable
# rather than merely consistent.
complexity:
- name: needs_reasoning
description: Escalate when the remote scorer puts the request past 0.80
hard_above: 0.80
easy_below: 0.30
- name: extreme
description: A stricter reading of the same score
hard_above: 0.95
easy_below: 0.10
decisions:
# A score of 0.90 is past needs_reasoning's hard boundary but inside
# extreme's medium band, so one remote call must reach two different
# verdicts. Local prototype scoring cannot produce that: it has no
# candidates to compare against here.
- name: complexity_backend_hard
description: Deterministic remote complexity backend routing contract
priority: 2000
rules:
operator: OR
conditions:
- type: complexity
name: needs_reasoning:hard
modelRefs:
- model: general-expert
use_reasoning: false
- name: complexity_backend_extreme
description: Reached only if the stricter rule also calls the request hard
priority: 3000
rules:
operator: OR
conditions:
- type: complexity
name: extreme:hard
modelRefs:
- model: general-expert
use_reasoning: false
- name: default-route
description: Fallback route, reached when the remote scorer produced no verdict
priority: 10
rules:
operator: AND
conditions: []
modelRefs:
- model: general-expert
use_reasoning: false
global:
router:
strategy: priority
model_catalog:
kbs: []
external:
- name: e2e-difficulty-scorer
model_role: classification
llm_endpoint:
address: mock-difficulty-scorer.default.svc.cluster.local
port: 8000
protocol: http
llm_model_name: difficulty-service
llm_timeout_seconds: 5
modules:
complexity:
# score.v1 rather than label_distribution.v1: the scorer returns a
# number and the rules above convert it. contract is required
# because complexity reads both, so omitting it would leave the
# runtime guessing which response shape to expect.
backend:
protocol: http_classify
contract: score.v1
model: e2e-difficulty-scorer
deadline_ms: 5000
services:
response_api:
enabled: true
store_backend: memory
ttl_seconds: 3600
max_responses: 1000

resources:
# 2Gi OOMKilled the router (exit 137) while it loaded the local mmbert
# embedding model the response cache is configured with. Taking the
# complexity scorer off-box does not avoid that load: the embedding models
# are gated separately from the classifier backend. The category profile
# runs in 2Gi because it configures no complexity rules and so resolves a
# different set of startup dependencies.
limits:
memory: 6Gi
cpu: "2"
requests:
memory: 2Gi
cpu: 250m
Loading
Loading