Skip to content

[Router] fix recipe-blind routing reads and pin every read scope - #2724

Closed
drivebyer wants to merge 10 commits into
mainfrom
refactor/router-recipes-single-source
Closed

[Router] fix recipe-blind routing reads and pin every read scope#2724
drivebyer wants to merge 10 commits into
mainfrom
refactor/router-recipes-single-source

Conversation

@drivebyer

@drivebyer drivebyer commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #2723 (part of #2331). Follow-up to #2612 / #2613; the flat-field removal sketched alongside that issue is deferred, see Non-goals.

Purpose

#2612/#2613 added named recipes next to the flat IntelligentRouting decisions field, but left ~15 whole-surface read sites reading only the default profile. The failures were silent: a memory plugin configured on a recipe decision passed validation and never enabled the runtime, five contract validators skipped recipe decisions entirely, and the replay recorder for a recipe decision was never registered. Named recipes are unreleased, so no released configuration is affected — but the reference config already promotes them.

This PR is the scoped correction, three layers:

  1. Rename the flat field DecisionsDefaultDecisions (mechanical, 106 files). The yaml/json tags keep the config format and the /info/classifier wire key byte-identical; the new name makes every default-profile read visible at its call site.
  2. Fix the read sites: plugin enablement, the five validators, lookups, and the replay recorder now read the whole routing surface via AllRoutingDecisions() / GetDecisionByName(). Sites whose scope really is the default profile stay that way, each with its rationale recorded.
  3. Guard the future: an allowlist test pins the complete set of production files that may read the flat field, one scope rationale per entry. A new default-profile read fails CI until it either switches to a whole-surface accessor or states its reason in front of the reviewer.

A review pass over the branch then tightened the scope decisions themselves:

  • The process-level selection singleton (elo/multi_factor/… configs) and the classify-API reasoning/recommendation paths deliberately stay on the default profile — widening them made the global config dependent on YAML recipe order and let recipes leak into an API that previews default-profile routing.
  • candidate_set: tier learning candidates now follow the request's recipe scope instead of unioning every profile (which could route past a recipe's entrypoint).
  • Cross-recipe decision names are now validated case-insensitively; the fold lookup's uniqueness assumption was otherwise false.
  • Cleanups: the memory-enablement predicate sank into config (was copied in three packages), allocation-free CountRoutingDecisions(), and the mirror invariant asserts slice aliasing rather than tautological DeepEqual.

No user-visible behavior change on any released surface: single-profile configs are bit-for-bit unaffected, and the only behavior changes sit on the unreleased named-recipe surface.

Non-goals (deferred, tracked)

  • Removing the flat field ("single source of truth"): the field and the default recipe are one aliased slice, pinned by an invariant test — removal buys no additional correctness today and costs an emitter compatibility layer plus a wholesale fixture rewrite. Revisit in feat: support entrypoints and multiple routing recipes #2331 if the recipes-only layout ever forces it.
  • Dashboard recipe awareness: registered as TD045, owned by pl-0038 T12.
  • DSL round-trip for recipes/entrypoints: pl-0038 T10.

Test Plan

  • Full main-module go test (pkg/cache excluded: env-dependent backends), including the cgo-linked extproc/classification/services/apiserver suites.
  • make agent-lint over all changed files, make check-go-mod-tidy, markdownlint on the docs.
  • New coverage: recipe_read_sites_test.go pins every corrected read site; default_decisions_allowlist_test.go pins the deliberate default-profile reads (and also matches the DefaultRecipe() spelling); recipes_invariant_test.go pins the flat-field/default-recipe aliasing; a new loader test pins the case-insensitive cross-recipe name check.

Test Result

All of the above pass locally; every commit builds and passes lint, so the sequence bisects.


Semantic Router PR Checklist
  • PR title uses module-aligned prefixes such as [Router], [CLI], [Dashboard], [Operator], [Fleet-Sim], [Bindings], [Training], [E2E], [Docs], or [CI/Build]
  • If the PR spans multiple modules, the title includes all relevant prefixes
  • Commits in this PR are signed off with git commit -s
  • The Purpose, Test Plan, and Test Result sections reflect the actual scope, commands, and blockers for this change

@drivebyer
drivebyer requested review from Xunzhuo and rootfs as code owners July 29, 2026 05:56
@netlify

netlify Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploy Preview for vllm-semantic-router ready!

Name Link
🔨 Latest commit ce13c72
🔍 Latest deploy log https://app.netlify.com/projects/vllm-semantic-router/deploys/6a6eeb0fa0a42c0008a8b338
😎 Deploy Preview https://deploy-preview-2724--vllm-semantic-router.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

👥 vLLM Semantic Team Notification

The following members have been identified for the changed files in this PR and have been automatically assigned when their GitHub accounts are assignable in this repository:

📁 dashboard

Owners: @FAUST-BENCHOU, @shraderdm, @drivebyer, @ramkrishs, @WUKUNTAI-0211, @AayushSaini101, @siloteemu
Files changed:

  • dashboard/backend/handlers/topology_response.go

📁 docs

Owners: @FAUST-BENCHOU, @shraderdm, @drivebyer, @ramkrishs, @WUKUNTAI-0211, @AayushSaini101, @siloteemu
Files changed:

  • docs/agent/plans/pl-0038-entrypoints-recipes.md
  • docs/agent/tech-debt/README.md
  • docs/agent/tech-debt/td-045-dashboard-recipe-scope-blindness.md

📁 e2e

Owners: @FAUST-BENCHOU, @shraderdm, @drivebyer, @ramkrishs, @WUKUNTAI-0211, @AayushSaini101, @siloteemu
Files changed:

  • e2e/testcases/apiserver_classification_endpoints.go

📁 perf

Owners: @FAUST-BENCHOU, @shraderdm, @drivebyer, @ramkrishs, @WUKUNTAI-0211, @AayushSaini101, @siloteemu
Files changed:

  • perf/benchmarks/decision_bench_test.go

📁 src/semantic-router

Owners: @FAUST-BENCHOU, @shraderdm, @drivebyer, @ramkrishs, @WUKUNTAI-0211, @AayushSaini101, @siloteemu
Files changed:

  • src/semantic-router/cmd/main.go
  • src/semantic-router/cmd/runtime_bootstrap.go
  • src/semantic-router/pkg/apiserver/route_classification_metrics.go
  • src/semantic-router/pkg/apiserver/route_classification_platform_test.go
  • src/semantic-router/pkg/apiserver/route_config_deploy_test.go
  • src/semantic-router/pkg/apiserver/route_config_runtime_sync_test.go
  • src/semantic-router/pkg/apiserver/route_models_test.go
  • src/semantic-router/pkg/apiserver/runtime_state_test.go
  • src/semantic-router/pkg/apiserver/server.go
  • src/semantic-router/pkg/apiserver/server_memory_test.go
  • src/semantic-router/pkg/classification/classifier_category_entropy.go
  • src/semantic-router/pkg/classification/classifier_domain_test.go
  • src/semantic-router/pkg/classification/classifier_lifecycle_test.go
  • src/semantic-router/pkg/classification/classifier_model_select.go
  • src/semantic-router/pkg/classification/classifier_signal_decision.go
  • src/semantic-router/pkg/classification/classifier_signal_decision_test.go
  • src/semantic-router/pkg/classification/classifier_signal_groups_test.go
  • src/semantic-router/pkg/classification/legacy_factory_test.go
  • src/semantic-router/pkg/classification/mcp_classifier_runtime.go
  • src/semantic-router/pkg/classification/signal_skip_test.go
  • src/semantic-router/pkg/config/canonical_config.go
  • src/semantic-router/pkg/config/canonical_export.go
  • src/semantic-router/pkg/config/canonical_loader_test.go
  • src/semantic-router/pkg/config/canonical_recipes.go
  • src/semantic-router/pkg/config/canonical_recipes_global_names_test.go
  • src/semantic-router/pkg/config/canonical_recipes_test.go
  • src/semantic-router/pkg/config/canonical_routing_loader.go
  • src/semantic-router/pkg/config/config.go
  • src/semantic-router/pkg/config/config_test.go
  • src/semantic-router/pkg/config/default_decisions_allowlist_test.go
  • src/semantic-router/pkg/config/fusion_config.go
  • src/semantic-router/pkg/config/helper.go
  • src/semantic-router/pkg/config/loader.go
  • src/semantic-router/pkg/config/loader_subscription_test.go
  • src/semantic-router/pkg/config/module_runtime_helpers.go
  • src/semantic-router/pkg/config/plugin_config_test.go
  • src/semantic-router/pkg/config/recipes.go
  • src/semantic-router/pkg/config/recipes_invariant_test.go
  • src/semantic-router/pkg/config/recipes_registry_test.go
  • src/semantic-router/pkg/config/remom_config.go
  • src/semantic-router/pkg/config/routing_signal_usage_test.go
  • src/semantic-router/pkg/config/validator_complexity.go
  • src/semantic-router/pkg/config/validator_complexity_test.go
  • src/semantic-router/pkg/config/validator_decision_emit_test.go
  • src/semantic-router/pkg/config/validator_domain.go
  • src/semantic-router/pkg/config/validator_domain_test.go
  • src/semantic-router/pkg/config/validator_learning.go
  • src/semantic-router/pkg/config/validator_learning_test.go
  • src/semantic-router/pkg/config/validator_memory_test.go
  • src/semantic-router/pkg/config/validator_modality.go
  • src/semantic-router/pkg/config/validator_projection.go
  • src/semantic-router/pkg/config/validator_rag_test.go
  • src/semantic-router/pkg/config/validator_semantic_cache_test.go
  • src/semantic-router/pkg/config/validator_test.go
  • src/semantic-router/pkg/config/workflows_config.go
  • src/semantic-router/pkg/dsl/compiler_rag_max_context_length_test.go
  • src/semantic-router/pkg/dsl/compiler_routes.go
  • src/semantic-router/pkg/dsl/decompiler.go
  • src/semantic-router/pkg/dsl/decompiler_decisions.go
  • src/semantic-router/pkg/dsl/decompiler_plugin_config_test.go
  • src/semantic-router/pkg/dsl/dsl_test.go
  • src/semantic-router/pkg/dsl/emit_retention_test.go
  • src/semantic-router/pkg/dsl/feedback_recipe_roundtrip_test.go
  • src/semantic-router/pkg/dsl/fusion_algorithm_test.go
  • src/semantic-router/pkg/dsl/maintained_asset_roundtrip_test.go
  • src/semantic-router/pkg/dsl/mmlu_recipe_roundtrip_test.go
  • src/semantic-router/pkg/dsl/privacy_recipe_roundtrip_test.go
  • src/semantic-router/pkg/dsl/routing_contract.go
  • src/semantic-router/pkg/dsl/taxonomy_dsl_test.go
  • src/semantic-router/pkg/dsl/taxonomy_e2e_test.go
  • src/semantic-router/pkg/dsl/workflows_algorithm_test.go
  • src/semantic-router/pkg/extproc/cache_personalization_pipeline_test.go
  • src/semantic-router/pkg/extproc/extproc_test.go
  • src/semantic-router/pkg/extproc/processor_req_body_anthropic_test.go
  • src/semantic-router/pkg/extproc/processor_res_cache_retention_test.go
  • src/semantic-router/pkg/extproc/processor_res_cache_status_test.go
  • src/semantic-router/pkg/extproc/processor_res_cache_test.go
  • src/semantic-router/pkg/extproc/recipe_read_sites_test.go
  • src/semantic-router/pkg/extproc/req_filter_cache_scope_test.go
  • src/semantic-router/pkg/extproc/req_filter_classification_runtime.go
  • src/semantic-router/pkg/extproc/req_filter_entrypoint_test.go
  • src/semantic-router/pkg/extproc/req_filter_fusion.go
  • src/semantic-router/pkg/extproc/req_filter_looper_test.go
  • src/semantic-router/pkg/extproc/req_filter_reason.go
  • src/semantic-router/pkg/extproc/req_filter_reason_test.go
  • src/semantic-router/pkg/extproc/req_filter_sys_prompt_test.go
  • src/semantic-router/pkg/extproc/router_build.go
  • src/semantic-router/pkg/extproc/router_components_mapping_gate_test.go
  • src/semantic-router/pkg/extproc/router_config_test.go
  • src/semantic-router/pkg/extproc/router_learning_adaptation.go
  • src/semantic-router/pkg/extproc/router_learning_selection_test.go
  • src/semantic-router/pkg/extproc/router_memory.go
  • src/semantic-router/pkg/extproc/router_replay_enablement_test.go
  • src/semantic-router/pkg/extproc/router_replay_setup.go
  • src/semantic-router/pkg/extproc/router_selection.go
  • src/semantic-router/pkg/extproc/router_selection_config_test.go
  • src/semantic-router/pkg/extproc/server_config_watch.go
  • src/semantic-router/pkg/k8s/converter_test.go
  • src/semantic-router/pkg/k8s/dynamic_config_regression_test.go
  • src/semantic-router/pkg/modeldownload/config_parser_test.go
  • src/semantic-router/pkg/modelruntime/router_runtime.go
  • src/semantic-router/pkg/modelselection/config_analyzer.go
  • src/semantic-router/pkg/services/classification.go
  • src/semantic-router/pkg/services/classification_recommendation.go
  • src/semantic-router/pkg/services/classification_signal_contract.go
  • src/semantic-router/pkg/services/classification_test.go
  • src/semantic-router/pkg/services/classification_update_test.go
  • src/semantic-router/pkg/utils/pii/policy_test.go

📁 tools

Owners: @FAUST-BENCHOU, @shraderdm, @drivebyer, @ramkrishs, @WUKUNTAI-0211, @AayushSaini101, @siloteemu
Files changed:

  • tools/agent/repo-manifest.yaml

vLLM Semantic Router

🎉 Thanks for your contributions!

This comment was automatically generated based on the OWNER files in the repository.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

✅ Supply Chain Security Report — All Clear

Scanner Status Findings
AST Codebase Scan (Py, Go, JS/TS, Rust) 31 finding(s) — MEDIUM: 24 · LOW: 7
AST PR Diff Scan No issues detected
Regex Fallback Scan No issues detected

Scanned at 2026-08-02T07:01:54.626Z · View full workflow logs

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Performance Benchmark Results

Component benchmarks completed with no regressions beyond thresholds.

Summary

  • Classification benchmarks: ✅
  • Decision engine benchmarks: ✅
  • Cache benchmarks: ✅
  • Looper family benchmarks: ✅
  • Regression gate: ✅ no regressions beyond thresholds

Details

See attached benchmark artifacts for detailed results and profiles.


Performance testing powered by vLLM Semantic Router

Part of the #2331 recipe work: the flat IntelligentRouting decisions
field holds only the default routing profile, but its name suggested the
whole routing surface. Rename it so default-profile reads are visible at
every call site and the field carries a distinct literal that read-scope
tooling can audit.

Pure rename; no behavior change. The yaml and json tags keep the
serialized names, so the config file format and the /info/classifier
response are byte-for-byte unchanged.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
…on and lookup sites

DefaultDecisions carries only the default recipe, so every site that reasons
about routing as a whole silently ignored named recipes. Migrate those sites
to AllRoutingDecisions()/GetDecisionByName() and leave the request-path
default-profile sites alone.

Four reads were measurably wrong for a decision living in a named recipe:

- the classifier could not resolve its models or system prompt
- its reasoning_effort was dropped, falling back to the global default
- its memory plugin never enabled the memory runtime
- its Router Replay recorder was never registered

The memory pair was the worst shape: validator_memory already validated a
recipe's memory plugin, while isMemoryEnabled never enabled it, so the config
passed validation and then did nothing.

Five contract validators (domain, projection, complexity, modality, learning)
also only walked the default profile, leaving recipe decisions' signal and
projection references entirely unchecked.

Named recipes are the unreleased #2612/#2613 surface, so nothing changes for
any released configuration; single-profile configs are bit-for-bit unaffected
because their whole surface IS the default profile.

Algorithm virtual slugs deliberately stay default-profile-only. They select
decisions without consulting the entrypoint table, so widening them would let
a recipe's decisions be selected without going through that recipe's
entrypoint. HasXDecision() stays default-scoped for the same reason: it gates
whether /v1/models advertises the slug, and advertising one that cannot route
would be a lie.

The by-name lookups are allocation free: getReasoningEffort runs several
times per request, so it resolves through GetDecisionByName rather than
materializing a merged decision slice.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
The rename made default-profile reads visible, but nothing stops a new
production site from picking that scope by accident: before the
read-site fixes, four validators read the whole surface while five read
the default profile, with no marker distinguishing them.

Pin the complete set of production files that may reference
DefaultDecisions, one comment per entry saying why the default-profile
scope is correct there. A new site fails the test until it either
switches to a whole-surface accessor or documents its scope here, which
puts the choice in front of the reviewer instead of leaving it silent.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
…lt profile

Three of the read-site widenings overshot. Narrow them back:

- findDecisionScopedSelectionConfigs feeds first-match algorithm configs
  (elo, router_dc, rl_driven, gmtrouter, multi_factor) into the
  process-level selector singleton. Reading every recipe made that
  global config claimable by a named recipe and dependent on YAML
  declaration order: recipes-only layouts preserve the declared order,
  so swapping two recipe blocks changed routing behavior. A singleton
  can have one config source; that source is the default profile.

- The entropy and MCP reasoning maps and the keyword-category lookup
  serve only the classify API, whose decision engine evaluates the
  default profile. Widening them let a named recipe's use_reasoning
  leak into metrics and logs for requests that can never route to that
  recipe's decisions. One API, one scope.

- getRecommendedModel could recommend a model reachable only through a
  named recipe's entrypoint while the same response's decision_result
  came from the default profile; under a recipes-only layout every
  classify call took that path. The recommendation now reads the
  default profile directly, keeping the response internally coherent.

The extproc request path is untouched: per-request lookups resolve the
already-selected decision by its globally unique name and stay
recipe-wide.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
… profile

candidate_set: tier unions the model refs of every decision sharing the
matched decision's tier. Reading the whole routing surface let a tier
match pull in models declared only by another recipe's decisions, and a
sampling win then rewrote the route to a model the request's entrypoint
never authorized. The default-profile baseline had the mirrored problem
(default models leaking into recipe-scoped requests) plus a worse
failure mode: with the base model absent from the candidate set, the
score-margin fallback rewrote every request unconditionally.

Tier candidates now follow the request's decision scope, mirroring
decisionCandidatesForRequest: the entrypoint recipe when one is scoped,
the default profile otherwise. candidate_set: global keeps the whole
surface — it is the deployed model inventory by contract.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
findDecisionBy's contract says the first match is the only match, but
the load-time uniqueness check compared names exactly while
GetDecisionByNameFold matches case-insensitively: two profiles could
declare names differing only by case and silently alias each other in
every fold lookup. Fold the cross-profile check; the within-profile
exact rule is unchanged, so no accepted single-profile config is
affected (all shipped decision names are lower-case snake_case).

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
isMemoryEnabled, shouldInitMemoryStore and memoryConfigured were three
byte-identical copies of the same predicate in three packages — the
read-site fix had to repair all three, and a fourth consumer would have
copied whichever it found first. One IsMemoryEnabled accessor now lives
in config next to IsHallucinationModelEnabled, the same shape for the
same layer; MemoryPluginDecisionName keeps the extproc auto-enable log
naming the triggering decision.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
…ath cleanups

- CountRoutingDecisions joins HasRoutingDecisions as the allocation-free
  counting sibling; the six decision_count sites (parse/reload logs and
  the classification metrics endpoint) no longer materialize the merged
  slice just to take its length.
- reasoningEffortForDecision takes the decision by pointer: the caller
  sits on the per-request path whose comment advertises the
  allocation-free lookup, and the value deref copied the 13-field
  Decision struct on every call.
- GetModelsForCategory resolves through findDecision like the rest of
  the classifier, removing the second spelling of the same lookup.
- Three comments still named the pre-rename Decisions field; the field
  name is the audit signal, so stale spellings send readers grepping
  for a field that no longer exists.
- AllRoutingDecisions documents that element order is unspecified,
  keeping first-match consumers from re-growing an implicit contract.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
…ixtures

- The guard now also matches DefaultRecipe(): rewriting a read site from
  cfg.DefaultDecisions to cfg.DefaultRecipe().Decisions is semantically
  identical and would have silenced the guard for free. Extending the
  spelling set costs zero new allowlist entries today.
- Repo-root discovery reuses repoRootFromTestFile like every other
  repo-walking test in this package, and the sanity probe checks a
  source-tree marker instead of .git (.dockerignore strips .git/, and a
  source tarball carries none). The walk skips Rust target/ trees and
  matches on raw bytes instead of copying each file into a string.
- The mirror invariant now asserts the two views share one backing
  array: DeepEqual alone is satisfied by an equal copy, so a defensive
  copy in the loader would have passed the very test meant to catch it.
- The invariant tests reuse the canonical recipe fixtures instead of
  carrying near-identical private copies, and the RoutingProfileSignals
  scope assertion moves next to the existing registry-merge test. One
  fixture, one invariant, one place to update.
- The ValidateEndpoints subtest no longer implies a load-time endpoint
  check exists; it pins the helper's recipe scope and says so.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
@drivebyer
drivebyer force-pushed the refactor/router-recipes-single-source branch from f0f9bcc to ea16957 Compare August 1, 2026 16:16
@drivebyer drivebyer changed the title [Router] refactor: make Recipes the single source of routing decisions [Router] fix recipe-blind routing reads and pin every read scope Aug 1, 2026
…d gap as TD045

The plan file still named the pre-rename Decisions field and its loop
state stopped at #2613; the operating rule now points new
default-profile reads at the allowlist. The dashboard's recipe
blindness (topology panel, setup wizard activation gate, config
projection) outlives this PR, so it gets a durable TD entry owned by
pl-0038 T12 instead of living only in a test comment.

Signed-off-by: drivebyer <wuyangmuc@gmail.com>
@drivebyer

Copy link
Copy Markdown
Collaborator Author

Superseded by #2741, which completes the rest of #2331 from the issue author and redesigns the recipe model along the way (recipe-local decision names, per-recipe classifier/selector scopes). That branch also fixes the read-scope defects this PR targeted — memory-plugin enablement, replay recorder registration, the contract validators — on the new model, so the whole-surface accessors, the cross-recipe name validation, and the allowlist guard built here no longer apply. The defect catalogue in #2723 should now be tracked against #2741.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Router] Whole-surface routing reads silently see only the default profile after named recipes

6 participants