GitHub user purushah created a discussion: [Discussion] Pluggable model routing 
— API & design (PR #852 follow-up)

Following the review on #852 (thanks @xintongsong for the thorough look) — 
opening this to align on the **design** before going deeper on implementation. 
PR #852 is a working **prototype/reference**; below is a refined design and the 
considerations behind it. Feedback welcome.

## 1 · What & why

Let an agent route each request to the most appropriate chat model instead of 
pinning one — send cheap/simple asks to a small model and hard ones to a strong 
model (cost + quality). The prototype models this as a drop-in 
`ChatModelRouter` (a `CHAT_MODEL` an agent points at by name), with a pluggable 
selection strategy, optional fallback, and per-conversation caching.

## 2 · Core proposal: routing should *select*, not *delegate*

In the prototype the router is a `ChatModelSetup` that calls the chosen 
backend's `chat()` internally. That one fact causes the two issues raised in 
review: the backend call and the LLM-judge call happen *inside* one resource, 
so they're invisible to the EventLog and their metrics collapse onto the router.

**Proposal:** make routing a **framework capability** (a dedicated router 
resource) that returns a model *name*; the framework then runs the **normal** 
chat path against that model.

- **Observability** — the chosen backend call is an ordinary 
`ChatRequestEvent`/`ChatResponseEvent` in the EventLog; the decision is its own 
`ModelRoutingEvent`; an LLM judge's call goes through the standard chat path 
too. (This is also what keeps routing compatible with the tracing/evaluation 
direction in #710 — the decision is data, not a black box.)
- **Metrics** — token usage attributes to the actual backend; the router only 
adds routing metrics (per-candidate routed count, fallback count, cache 
hit/miss).
- **Bonus** — tools/prompt/skills (cf. #565) attach to the chosen model, not 
the router.

**One detail to settle** under this model: how the LLM-judge call is 
represented — a full `ChatRequest`/`Response` in the loop (most transparent, 
bigger change) vs. a lighter `ModelRoutingEvent` carrying its reply. I lean 
toward the lighter event first, with the full path as a follow-up.

## 3 · Public API: function-first, with built-ins

Every well-regarded routing framework I looked at makes **a function the front 
door** and ships a few built-ins — none require authoring rules in a nested map 
as the primary API:

| Framework | Mechanism |
|---|---|
| Apache Camel (Content-Based Router) | `Predicate.matches(Exchange)` in 
`.choice().when(...)` |
| LangChain4j | `QueryRouter.route(Query)` + built-ins 
(`LanguageModelQueryRouter`) |
| Kafka Streams | `split().branch(Predicate)` |
| Spring Integration | `@Router` / `AbstractMessageRouter` |
| RouteLLM (LMSYS) | pluggable router types (bert / causal_llm / sw_ranking) |

So the single public extension point is `RoutingStrategy` — a one-method 
interface returning a candidate name (or `null` to abstain). Everything else is 
config:

- **Built-in strategies** are `RoutingStrategy` objects handed in via small 
factories — `Strategies.rules(...)` (keyword/regex), `Strategies.llm(...)` 
(judge); later semantic/ML. Rule-based stays, but as just-a-built-in — **no 
magic-string dispatch** in code.
- **Fallback** and **caching** become **config flags** (`fallback`, 
`cache`/`cache_size`) — not interfaces users implement or wrap.
- Users implement only `RoutingStrategy`, and only for custom/ML.

## 4 · Framework vs. user — the target shape

Push everything mechanical into the framework; the user writes at most one 
function. Example shapes:

**A · Common case — config only, no routing code**

```java
ModelRouter.of("small", "big")
    .strategy(Strategies.rules(Map.of("big", "\\b(code|sql|analyze)\\b")))  // 
built-in factory, no magic string
    .defaultModel("small")
    .fallback(true)
    .cache(true);
```

**B · Custom — the only user code is one function (lambda)**

```java
ModelRouter.of("small", "big")
    .strategy(ctx -> ctx.firstUserMessage().length() > 200 ? "big" : "small")  
// lambda
    .defaultModel("small");
```

**C · LLM-as-router — a judge model decides (built-in)**

```java
ModelRouter.of("small", "big")
    .strategy(Strategies.llm("small"))  // judge model; candidates carry 
descriptions
    .defaultModel("small")
    .cache(true);   // judge runs ~once per conversation, not per tool-call 
round
```

**D · ML / learned — a reusable strategy class (bring-your-own)**

```java
public class LearnedRouter implements RoutingStrategy {
    public String route(RoutingContext ctx) {
        double hardness = myModel.score(ctx.firstUserMessage());  // trained 
classifier / scorer
        return hardness >= 0.5 ? "big" : "small";                 // null = 
abstain -> default
    }
}
// wire it:
ModelRouter.of("small", "big").strategy(new 
LearnedRouter()).defaultModel("small");
```

**Regex is just a rule (or a lambda)**

```java
// built-in rules with a regex (factory, not a name string):
.strategy(Strategies.rules(Map.of("big", "\\b(code|sql|prove|analyze)\\b")))
// or inline as a function, no config:
.strategy(ctx -> 
ctx.firstUserMessage().matches("(?is).*\\bselect\\b.*\\bfrom\\b.*") ? "big" : 
"small")
```

**The agent side — identical in every case**

```java
@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
public static void onInput(Event e, RunnerContext ctx) {
    String q = (String) InputEvent.fromEvent(e).getInput();
    ctx.sendEvent(new ChatRequestEvent("router", List.of(new ChatMessage(USER, 
q)), Map.of(), null));
}
```

Across all of these the agent's actions are unchanged — they just send 
`ChatRequestEvent("router", …)`. Only the **strategy** slot differs: rules → 
LLM → ML is a one-line change.

### Proposed API surface (names/semantics up for critique)

| Member | Meaning |
|---|---|
| `ModelRouter.of(String… candidates)` | Start a router over these candidate 
model names (order matters for fallback). |
| `.strategy(RoutingStrategy s)` | The **single** strategy setter — takes a 
strategy object. Built-ins come from factories (`Strategies.rules(...)`, 
`Strategies.llm(...)`); custom is a lambda or class instance. No name-string 
dispatch. |
| `.defaultModel(String name)` | Candidate used when the strategy abstains 
(`null`) or names a non-candidate. Must be one of the candidates. |
| `.fallback(boolean)` | On a chosen-model error, try the remaining candidates 
in declaration order (default `false`). |
| `.cache(boolean)` / `.cacheSize(int)` | Memoize the decision per conversation 
(sticky). Default on; bounded LRU. |
| `RoutingStrategy#route(RoutingContext) → String` | The SPI: return a 
candidate name, or `null` to abstain. (`@FunctionalInterface`, so a lambda 
works.) |
| `RoutingContext` | What the strategy sees: request messages, prompt args, 
candidates (name + description + metadata), and access to resources (e.g. an 
embedding model). |

Points I'd like input on: (a) built-ins are factories that return a 
`RoutingStrategy` (`Strategies.rules(...)`) — the open bit is only whether we 
prefer that or typed builder methods (`.rules(...)`, `.llm(...)`); either way, 
**no hardcoded strategy keywords** in Java. (b) should `cache`/`fallback` be 
simple flags as above, or richer policy objects? (c) does `RoutingContext` 
expose enough (candidate descriptions/metadata, resource access) for 
ML/semantic strategies?

## 5 · Design considerations — prior art & proposed direction

These are the decisions that shape the design. For each, I've laid out how 
established products already solve it and the direction I'm proposing, so we're 
converging on a concrete plan rather than starting from scratch. Refinements 
welcome.

### Consideration 1 · Strategies are objects, not name strings — with built-in 
factories

**Prior art** — the split is about *who dispatches on the name*. In-code APIs 
pass **objects**: LangChain4j hands in a router instance (`new 
LanguageModelQueryRouter(...)`), Camel/Kafka Streams take a `Predicate`. Only 
LiteLLM selects a built-in by string (`latency-based`/`cost-based`/…) — because 
it's a **config-driven proxy**, the declarative exception, and even its 
built-ins are operational, not content-rules.

**Proposal** — **no hardcoded strategy keywords in Java**. One setter, 
`strategy(RoutingStrategy)`; built-ins are factories (`Strategies.rules(...)`, 
`Strategies.llm(...)`) and custom is a lambda. A name string is unavoidable 
only in declarative config (YAML — cf. #662 — or SQL), and there we use the 
**fully-qualified class name** (open/extensible), with a short alias as 
optional sugar, not a baked-in framework keyword. Keep a thin `rules` built-in 
(handy for the declarative/SQL path); operational strategies (latency/cost) are 
a natural future built-in set — added as more factories, still no dispatch 
strings.

### Consideration 2 · ML/learned routing is bring-your-own, with 
embedding-similarity as the first built-in

**Prior art** — RouteLLM ships trained routers as in-process artifacts (BERT / 
causal-LLM / `sw_ranking`); vLLM Semantic Router uses an in-process embedding 
classifier. Learned/content routing is typically an in-process artifact or 
embedding classifier, not an external service.

**Proposal** — BYO for v1 via `RoutingStrategy`; make embedding-similarity (a 
registered embedding-model resource) the first built-in ML path (no training 
pipeline needed); trained-classifier-as-artifact as a follow-up.

### Consideration 3 · Stay consistent with Flink SQL routing, but don't couple 
to it

**Prior art** — no LLM gateway is a SQL engine, so there's no direct analog; 
Flink's own `ML_PREDICT` (FLINK-39961) is the closest.

**Proposal** — keep semantics consistent with the SQL work, but don't couple or 
block the agents PR on it.

---

**Cross-cutting** — LiteLLM treats fallback / retries / timeout / caching as 
plain Router config, validating "fallback/cache are flags, not interfaces." (It 
also selects strategy by a name string, but only because it's config-driven — 
our in-code API passes strategy *objects*, matching LangChain4j/Camel.) The 
proposed API is squarely in line with the most-used LLM router in the wild.

> PR #852 stays open as a prototype/reference. Happy to adjust it (or split it) 
> once we align here. Thoughts?


GitHub link: https://github.com/apache/flink-agents/discussions/897

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to