Skip to content

feat(ext): add route selector extension point and adaptive strategy - #611

Open
SantiagoDePolonia wants to merge 3 commits into
mainfrom
feat/ext-route-selector
Open

feat(ext): add route selector extension point and adaptive strategy#611
SantiagoDePolonia wants to merge 3 commits into
mainfrom
feat/ext-route-selector

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a routing extension point to the public ext API so extension builds can steer load balancing for virtual models:

  • ext.RouteSelector (ext/route.go): Select picks among currently viable targets of a load-balanced virtual model; OnAttemptStart/OnAttemptEnd observe every upstream attempt (primaries, retries, failovers) via the llmclient hooks, so selectors score targets from real traffic. Registered through a single slot on the registry (RegisterRouteSelector).
  • New adaptive load-balancing strategy for virtual models: delegates target choice to the registered selector. Candidates carry provider/model, configured weight, and registry pricing.
  • Wiring (internal/app/app.go): selector hooks attach before providers are created (same rule as the health tracker); the virtual-models service gets the selector alongside the capacity probe.

Safety / portability

  • Session affinity, rate-limit capacity filtering, failover chains, and retries all stay in core — the selector only picks the primary among viable targets.
  • Fail-open by design: no selector registered, selector declines, answers outside the pool, or panics (recovered and logged, both in selection and observation) → exact round_robin behavior. Configs using adaptive stay portable between plain core and extended builds.
  • Single-viable-target pools bypass the selector, so aliases behave identically with and without one.
  • An empty registry keeps adding zero request overhead.

Tests

  • ext: registry single-slot semantics, RouteTarget.Qualified().
  • virtualmodels: selector delegation (request shape incl. pricing), fallback matrix (no selector / decline / out-of-pool answer / panic), single-target bypass.
  • Full suite + race detector green (pre-commit make test-race).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an adaptive virtual-model load-balancing strategy.
    • Enabled extensions to influence model selection using session/candidate and attempt lifecycle context.
    • Implemented defensive behavior: if the selector is missing, declines, can’t choose within the pool, or errors, selection falls back to weighted round-robin.
  • Documentation
    • Updated configuration and administration docs to describe the new adaptive option and its requirements.
  • Tests
    • Added unit tests for adaptive selection, pricing metadata handling, fallback behavior, selector registration behavior, and route target formatting.

Extensions can now steer load balancing for virtual models. A new
ext.RouteSelector registers through the registry's single selector slot
and is consulted by redirects using the new "adaptive" strategy; it picks
among the currently viable targets (catalog-supported, with rate-limit
capacity) and observes every upstream attempt — primaries, retries, and
failovers — through the llmclient hooks, so it can score targets by real
traffic.

Session affinity, capacity filtering, failover chains, and retries all
remain core's responsibility. Without a registered selector — or when the
selector declines, answers outside the pool, or panics (contained and
logged) — the adaptive strategy behaves exactly like round_robin, so
configs stay portable between core and extended builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 17:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@SantiagoDePolonia, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3877d136-af05-4c85-b163-4747669640f5

📥 Commits

Reviewing files that changed from the base of the PR and between 189beca and b3c07a4.

📒 Files selected for processing (1)
  • internal/virtualmodels/adaptive_test.go
📝 Walkthrough

Walkthrough

Adds the adaptive virtual-model balancing strategy, a public extension route-selector contract, registry registration, application lifecycle hooks, selector-based target resolution, and round-robin fallback behavior with tests.

Changes

Adaptive routing

Layer / File(s) Summary
Routing contract and registry
ext/route.go, ext/registry.go, ext/ext.go, ext/registry_test.go
Defines route candidates, requests, outcomes, and selector callbacks; adds registry storage and registration APIs for one route selector.
Adaptive strategy and fallback
internal/virtualmodels/*, config/..., internal/admin/handler_virtualmodels.go
Accepts the adaptive strategy, delegates target selection to an installed selector, and falls back to weighted round robin when selection is unavailable or invalid.
Application hook integration
internal/app/app.go
Registers selector attempt hooks with the provider factory and installs the selector into the virtual-model service during startup.
Adaptive routing validation
internal/virtualmodels/adaptive_test.go
Tests selector delegation, candidate pricing metadata, fallback scenarios, panic handling, and single-target bypass behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ExtensionRegistry
  participant App
  participant ProviderFactory
  participant VirtualModelService
  participant RouteSelector
  ExtensionRegistry->>App: expose registered selector
  App->>ProviderFactory: install attempt hooks
  App->>VirtualModelService: install selector
  VirtualModelService->>RouteSelector: select from RouteRequest
  ProviderFactory->>RouteSelector: report attempt lifecycle
Loading

Possibly related PRs

  • ENTERPILOT/GoModel#416: Introduces the shared virtual-model service and application wiring extended by this route-selector integration.
  • ENTERPILOT/GoModel#433: Changes the virtual-model balancing strategy logic extended here with adaptive selection.
  • ENTERPILOT/GoModel#492: Modifies the same target-resolution path used by adaptive balancing.

Poem

I’m a rabbit routing through the model meadow,
Choosing bright paths where the tokens flow.
If the selector naps, round-robin takes flight,
Hooks share each attempt from morning to night.
Adaptive carrots now grow in the code—
Hop, test, and ship down the balanced road!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately captures the main change: adding a route selector extension point and adaptive strategy.
Description check ✅ Passed The description covers the main changes, safety notes, and tests, though it uses custom headings instead of the exact template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ext-route-selector

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 28, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 54.54545% with 45 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/app/app.go 2.56% 35 Missing and 3 partials ⚠️
internal/virtualmodels/service.go 66.66% 2 Missing and 2 partials ⚠️
internal/virtualmodels/adaptive.go 93.75% 1 Missing and 1 partial ⚠️
ext/registry.go 88.88% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/config.example.yaml`:
- Line 64: Update the strategy comment near the strategy configuration example
to describe the routing extension as optional rather than required, while
preserving that adaptive is valid without a selector and falls back to
round_robin.

In `@ext/registry_test.go`:
- Around line 88-102: Convert TestRegistryRouteSelectorSingleSlot and
TestRouteTargetQualified into table-driven tests covering unset, replacement,
and nil-reset selector behavior plus route-target qualification. Reuse each
case’s inputs and expected outputs through subtests, preserving the current
assertions and behavior.

In `@internal/app/app.go`:
- Around line 117-127: Update the recovery handler inside routeSelectorHooks so
it performs no extension calls, including selector.Name(), and logs only fixed,
non-sensitive metadata such as the observation event. Remove the recovered panic
value from slog.Error while preserving panic containment and the existing error
message.

In `@internal/virtualmodels/adaptive_test.go`:
- Around line 82-88: Expand the request-translation assertions in the relevant
test to compare the complete req.Candidates slice against expected
ext.RouteCandidate metadata, including Weight, OutputPerMtok, InputPerMtok,
provider/model fields, and nil versus populated optional prices. Convert the
coverage to table-driven tests so multiple candidate and parameter-mapping cases
are validated, while preserving the existing request translation behavior.

In `@internal/virtualmodels/adaptive.go`:
- Around line 20-25: Update the deferred recovery handler around Select so panic
reporting does not call the extension-provided selector.Name(); use a constant
label or panic-safe helper while preserving the resolvedTarget{} false fallback.
Add a regression test covering both Select and Name panicking and verify
round-robin fallback is returned without another panic.
- Around line 22-23: Update the panic recovery logging around the route selector
fallback to stop including the raw recovered value r. Remove the panic field or
replace it with a non-sensitive panic classification, while preserving the
selector, source, and fallback behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fbb3af64-6418-4421-a897-87bf4dde574b

📥 Commits

Reviewing files that changed from the base of the PR and between 20aa6c5 and c31d624.

📒 Files selected for processing (14)
  • config/config.example.yaml
  • config/virtualmodels.go
  • ext/ext.go
  • ext/registry.go
  • ext/registry_test.go
  • ext/route.go
  • internal/admin/handler_virtualmodels.go
  • internal/app/app.go
  • internal/virtualmodels/adaptive.go
  • internal/virtualmodels/adaptive_test.go
  • internal/virtualmodels/balancer.go
  • internal/virtualmodels/service.go
  • internal/virtualmodels/types.go
  • internal/virtualmodels/validation.go

Comment thread config/config.example.yaml Outdated
Comment thread ext/registry_test.go
Comment thread internal/app/app.go
Comment thread internal/virtualmodels/adaptive_test.go Outdated
Comment thread internal/virtualmodels/adaptive.go
Comment thread internal/virtualmodels/adaptive.go Outdated
Comment thread internal/virtualmodels/adaptive.go Outdated
Comment on lines +21 to +23
if r := recover(); r != nil {
slog.Error("route selector panicked; falling back to round robin",
"selector", selector.Name(), "source", entry.vm.Source, "panic", r)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recovery handler can repanic

When a selector callback and its Name method both panic, the recovery handler calls selector.Name() while processing the first panic, allowing the second panic to escape and abort adaptive resolution instead of falling back to round robin. The observation recovery in internal/app/app.go has the same failure mode and can abort an upstream request.

Comment thread internal/app/app.go
Comment on lines +233 to +241
// An extension route selector observes every upstream attempt — primaries,
// retries, and failovers — to steer adaptive load balancing. Like the
// health tracker, its hooks must be attached before any provider exists.
var routeSelector ext.RouteSelector
if cfg.Extensions != nil {
routeSelector = cfg.Extensions.RouteSelector()
}
if routeSelector != nil {
cfg.Factory.AddHooks(routeSelectorHooks(routeSelector))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retry attempts remain unobserved

When an upstream request fails on retryable attempts before succeeding, these request-level hooks emit only one final successful outcome because the transport retry loop runs inside a single llmclient request scope. This breaks the public per-attempt observation contract and hides intermediate failures from adaptive selectors, causing unstable targets to receive inaccurately healthy scores.

Artifacts

Repro: focused mocked-upstream Go test and RouteSelector callback recorder

  • Evidence file captured while the check ran.

Repro: verbose failing test output showing three HTTP attempts but only two selector callbacks

  • The full command output behind this check.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread internal/virtualmodels/adaptive.go Outdated
Comment on lines +40 to +42
if model, found := s.catalog.LookupModel(t.qualified); found && model != nil && model.Metadata != nil && model.Metadata.Pricing != nil {
candidate.InputPerMtok = model.Metadata.Pricing.InputPerMtok
candidate.OutputPerMtok = model.Metadata.Pricing.OutputPerMtok

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Catalog prices escape by pointer

These fields expose the catalog's mutable *float64 values directly to extension code because LookupModel performs only a shallow model copy. A selector that modifies either value changes prices subsequently read by cost routing and cost calculation, and concurrent mutation can also introduce a data race; pass defensive copies instead.

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Confidence Score: 2/5

The change should not merge until selector panics are fully contained and retry attempts are reported according to the public contract.

Recovery can itself panic through selector.Name(), while transport retries are collapsed into one observation despite the API promising one observation per attempt; candidate pricing also exposes mutable shared catalog values.

Files Needing Attention: internal/app/app.go, internal/virtualmodels/adaptive.go, ext/route.go

T-Rex T-Rex Logs

What T-Rex did

  • Created a minimal test harness that installs a selector whose Select callback and Name method panic, then invokes adaptive model resolution to exercise round-robin fallback.
  • Ran a focused in-process Go test with a mocked upstream returning HTTP 429, HTTP 503, and then HTTP 200 to verify retry behavior.
  • Observed that three upstream attempts occurred but the selector produced no outcomes for the intermediate failures, and the contract assertion failed with exit code 1.
  • Ran named tests and race runs; all named tests passed with exit code 0, and race runs for ext and internal/virtualmodels completed successfully.
  • Collected and attached validation artifacts, including the reproducer test, verbose failure log, per-component race logs, the validation script, and the shell script used.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

sequenceDiagram
participant Client
participant VM as Virtual-model service
participant Selector as Route selector
participant Provider
participant Hooks as llmclient hooks
Client->>VM: Request adaptive virtual model
VM->>VM: Filter viable targets
alt Multiple viable targets and selector installed
    VM->>Selector: Select(RouteRequest)
    Selector-->>VM: Qualified target
else Selector declines, panics, or is absent
    VM->>VM: Weighted round-robin fallback
end
VM->>Provider: Upstream request
Provider->>Hooks: Request start/end
Hooks->>Selector: Attempt observations
Provider-->>Client: Response
Loading

Reviews (1): Last reviewed commit: "feat(ext): add route selector extension ..." | Re-trigger Greptile

Panic recovery around selector calls no longer re-enters extension code:
the selector's name is captured panic-safe at install time, so a Name
that panics mid-recovery can no longer escape the handler and abort the
request instead of falling back. The recovered value is no longer
logged — it is extension-controlled and could carry request data.

Candidate pricing is now defensively copied: selectors received the
catalog's own *float64 values, so a misbehaving extension could mutate
the prices that cost routing and cost calculation later read.

The per-attempt observation contract is corrected to match behavior:
hooks fire once per upstream call, and transport-level retries inside
the provider client are aggregated into that call's single outcome
(final status, duration spanning retry backoff). Moving observation
inside the client retry loop would change hook semantics for every
consumer (metrics, health tracking) and is out of scope here.

Also: registry selector-slot tests are table-driven (incl. nil reset),
the delegation test asserts the full candidate set, a regression test
covers the double-panic path, and the example config no longer implies
the adaptive strategy requires an extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 17:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 189beca:

  • Recovery re-panic (P1): the selector's name is now captured panic-safe at install time (selectorLabel, both in virtualmodels and app); recovery handlers log only fixed metadata and never call back into extension code. The raw panic value is no longer logged (extension-controlled, could carry request data). Regression test added for the Select-and-Name-both-panic path.
  • Per-attempt contract (P1): fixed the contract, not the plumbing — ext/route.go now documents that hooks fire once per upstream call, with transport-level retries inside the provider client aggregated into that call's outcome (final status; duration spans retry backoff, so retry-heavy targets still score slower). Moving observation inside the client retry loop would change llmclient.Hooks semantics for every existing consumer (Prometheus metrics, health tracker) and belongs in its own PR if we ever want per-transport-attempt observability.
  • Pricing escape (P2): candidates now carry defensive copies of the catalog's per-Mtok prices; test asserts a selector writing through the pointers cannot change what cost routing later reads.
  • CodeRabbit: example-config wording fixed (adaptive is optional, falls back to round_robin), registry selector-slot tests are table-driven (unset/single/replace/nil-reset), and the delegation test asserts the complete candidate set.

#612 has been rebased on top.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/virtualmodels/adaptive_test.go`:
- Around line 119-135: Expand TestBalancer_AdaptiveCandidatePricingIsCopied into
a table-driven isolation test that mutates both InputPerMtok and OutputPerMtok
for every selector candidate, then verifies the corresponding pricing pointers
for every catalog model remain unchanged. Use the existing candidate and catalog
model symbols to cover all candidates and both pricing fields, while preserving
the current defensive-copy assertion behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 10507680-ad9f-47ea-a5ff-fc0f9baa0dc0

📥 Commits

Reviewing files that changed from the base of the PR and between c31d624 and 189beca.

📒 Files selected for processing (7)
  • config/config.example.yaml
  • ext/registry_test.go
  • ext/route.go
  • internal/app/app.go
  • internal/virtualmodels/adaptive.go
  • internal/virtualmodels/adaptive_test.go
  • internal/virtualmodels/service.go

Comment thread internal/virtualmodels/adaptive_test.go
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 17:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Follow-up from the second review pass in b3c07a4: the pricing-isolation test now mutates every pricing pointer of every candidate and verifies all catalog prices stay intact.

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.

3 participants