Skip to content

Add models-router: multidimensional model selection - #110

Merged
bsbodden merged 1 commit into
mainfrom
feat/model-router
Aug 7, 2026
Merged

Add models-router: multidimensional model selection#110
bsbodden merged 1 commit into
mainfrom
feat/model-router

Conversation

@bsbodden

@bsbodden bsbodden commented Aug 7, 2026

Copy link
Copy Markdown
Member

Routes a request across in-process ModelJars artifacts and whatever hosted providers an application holds keys for.

The router selects and never calls. A decision names a model id; the application maps it back to its own client. That is why this module carries no OpenAI/Anthropic/DeepSeek SDK.

ModelRouter router = ModelRouter.builder()
    .candidates(List.of(localSmall, deepseek, gpt5, opus))
    .policy(RoutingPolicy.BALANCED)
    .build();

RoutingDecision decision = router.route("refactor this function");
ChatModel model = myClients.get(decision.modelId());

Behaviour on a realistic fleet

policy "say hello" "refactor this function" "prove this integral converges"
BALANCED qwen3-0.6b deepseek-v3 deepseek-v3
CHEAPEST qwen3-0.6b qwen3-0.6b deepseek-v3
FASTEST qwen3-0.6b qwen3-0.6b deepseek-v3
BEST_QUALITY claude-opus-5 claude-opus-5 claude-opus-5
LOCAL_FIRST qwen3-0.6b qwen3-0.6b deepseek-v3
PRIVACY_STRICT qwen3-0.6b qwen3-0.6b (refuses)

PRIVACY_STRICT refusing the math query is correct: no local model declares math, and silently using one that cannot do it would be worse than failing.

The single dial moves monotonically:

cqt=0  openai/gpt-5      cqt=4  deepseek/deepseek-v3    cqt=10 qwen3-0.6b-q4_0

Two design points that came out of building it

Reliability multiplies, it does not add. With availability as an additive term a model failing four calls in five still won on cost and latency. Any additive weight small enough to be reasonable lets a cheap, fast, broken model win. Availability weight now sets how sharply failures are punished, via successRate^(weight*10).

Tags filter, they do not merely inform. A model tagged {chat, code} was selected for a math query on price. Tags declare what a model is for; an untagged model stays general purpose.

BEST_QUALITY carries zero cost weight, deliberately. A token weight left it choosing the cheaper model whenever two were within a hair on quality — exactly the surprise a preset with that name must not deliver. There is a regression test for it.

Other properties

  • Hard constraints filter before scoring, and a refusal names every reason rather than whichever was evaluated last.
  • Sessions pin to their first choice while it stays eligible, since switching mid-conversation discards the provider's prompt cache.
  • Every decision carries a score breakdown: deepseek/deepseek-v3 for math (score 0.891, availability 1.000, cost 0.333, latency 0.167, locality 0.000, quality 0.391). A router nobody can interrogate is a router nobody can tune.
  • TaskClassifier is an interface so an embedding-similarity classifier over vectors-router drops in once that module reaches Central (Publish vectors-router to Maven Central vectors#57).

19 tests; build and complianceCheck pass.

🤖 Generated with Claude Code

Chooses which model answers a request across in-process ModelJars
artifacts and whatever hosted providers an application holds keys for.

The router selects and never calls, so applications keep their own
provider clients and this module carries no provider SDK. A decision
names a model id the application maps back to its own client.

Scoring is a weighted sum over normalized dimensions rather than a
difficulty classifier picking a tier. Difficulty alone cannot express
"this is easy but the cheap model is failing right now" or "this is hard
but the prompt does not fit the cheaper sibling's context", and routing
has to balance cost, latency, specialization and reliability at once.

Two design points came out of building it rather than planning it:

Reliability multiplies, it does not add. With availability as an
additive term, a model failing four calls in five still won on cost and
latency. Any additive weight small enough to be reasonable lets a cheap,
fast, broken model win. Availability weight now sets how sharply
failures are punished via successRate^(weight*10).

Tags filter, they do not merely inform. A model tagged {chat, code} was
being selected for a math query on price. Tags declare what a model is
for; an untagged model stays general purpose.

Presets are pre-tuned so nobody has to hand-weight five dimensions:
BALANCED, CHEAPEST, FASTEST, BEST_QUALITY, LOCAL_FIRST, PRIVACY_STRICT,
plus costQualityTradeoff(0..10) for a single dial. BEST_QUALITY carries
zero cost weight deliberately: a token weight left it picking a cheaper
model whenever two were within a hair on quality, which is exactly the
surprise that preset must not deliver.

Hard constraints filter before scoring, and a refusal names every reason
rather than whichever happened to be evaluated last. PRIVACY_STRICT on a
500k-token prompt refuses rather than silently choosing a model that
cannot hold it.

Sessions pin to their first choice while it stays eligible, because
switching models mid-conversation discards the provider's prompt cache.

TaskClassifier is an interface so an embedding-similarity classifier
over vectors-router drops in once that module is on Central.
@bsbodden
bsbodden merged commit 0b48f40 into main Aug 7, 2026
25 checks passed
@bsbodden
bsbodden deleted the feat/model-router branch August 7, 2026 15:51
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.

1 participant