This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 57949309 [api][plan][java] Validate router candidates and default 
model at declaration (#1151)
57949309 is described below

commit 57949309ab1ac392fab9c39a06b50b7c90e4710b
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Mon Sep 28 15:08:54 2026 +0900

    [api][plan][java] Validate router candidates and default model at 
declaration (#1151)
    
    Generated-by: Claude Code 2.1.280 (Claude Fable 5.1)
---
 .../agents/api/chat/model/routing/ModelRouter.java | 38 ++++-----
 .../model/routing/RoutingCandidateValidator.java   | 81 +++++++++++++++++++
 .../agents/api/chat/model/routing/RoutingTest.java | 90 +++++++++++++++++++++-
 docs/content/docs/development/model_routing.md     |  4 +-
 .../org/apache/flink/agents/plan/AgentPlan.java    | 17 ++--
 .../plan/AgentPlanLlmJudgeValidationTest.java      | 43 ++++++++++-
 .../agents/plan/AgentPlanRoutingBackstopTest.java  | 15 +++-
 7 files changed, 254 insertions(+), 34 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
index 9edf4349..a9de9b0c 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
@@ -27,11 +27,9 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 
 /**
  * A framework resource that <b>selects</b> a concrete chat model for a 
request. It does not call
@@ -63,6 +61,9 @@ public class ModelRouter extends Resource {
     /** Descriptor key carrying the custom executor class name ({@code CUSTOM} 
only). */
     public static final String STRATEGY_EXECUTOR_CLASS_KEY = 
"strategy_executor_class";
 
+    /** Descriptor argument naming the candidate the router lands on when the 
strategy abstains. */
+    public static final String DEFAULT_MODEL_KEY = "default_model";
+
     private final List<RoutingCandidate> candidates;
     private final String defaultModel;
     private final boolean fallbackEnabled;
@@ -72,28 +73,18 @@ public class ModelRouter extends Resource {
             throws Exception {
         super(descriptor, resourceContext);
         List<String> names = descriptor.getArgument(CANDIDATES_KEY);
-        if (names == null || names.isEmpty()) {
-            throw new IllegalArgumentException("ModelRouter requires at least 
one candidate.");
-        }
+        // Read as Object so a deserialized non-String default model is 
rejected by the validator
+        // with the candidate message instead of failing the cast here.
+        Object defaultModel = descriptor.getArgument(DEFAULT_MODEL_KEY);
+        RoutingCandidateValidator.validate("ModelRouter", names, defaultModel);
         Map<String, String> descriptions =
                 descriptor.getArgument("candidate_descriptions", 
Collections.emptyMap());
         List<RoutingCandidate> parsed = new ArrayList<>();
-        Set<String> uniqueNames = new LinkedHashSet<>();
         for (String name : names) {
-            if (!uniqueNames.add(name)) {
-                throw new IllegalArgumentException(
-                        String.format("ModelRouter candidate '%s' is 
duplicated.", name));
-            }
             parsed.add(new RoutingCandidate(name, descriptions.get(name)));
         }
         this.candidates = Collections.unmodifiableList(parsed);
-        this.defaultModel = descriptor.getArgument("default_model");
-        if (this.defaultModel != null && !isCandidate(this.defaultModel)) {
-            throw new IllegalArgumentException(
-                    String.format(
-                            "ModelRouter default model '%s' is not one of the 
candidates %s.",
-                            this.defaultModel, getCandidateNames()));
-        }
+        this.defaultModel = (String) defaultModel;
         this.fallbackEnabled =
                 Boolean.TRUE.equals(descriptor.getArgument("fallback", 
Boolean.FALSE));
         String typeTag = descriptor.getArgument(STRATEGY_TYPE_KEY);
@@ -208,10 +199,21 @@ public class ModelRouter extends Resource {
             return this;
         }
 
+        /**
+         * Builds the descriptor to register after checking the declaration.
+         *
+         * @throws IllegalStateException if no strategy was set
+         * @throws IllegalArgumentException if the candidate list is empty, a 
candidate name is
+         *     empty or duplicated, the default model is not a candidate, or a 
rule key is not a
+         *     candidate
+         */
         public ResourceDescriptor build() {
             if (strategy == null) {
                 throw new IllegalStateException("ModelRouter requires a 
strategy(...).");
             }
+            // Same check as the constructor: a duplicate or blank candidate, 
or a default model
+            // that is not a candidate, fails at the registration call site.
+            RoutingCandidateValidator.validate("ModelRouter", candidates, 
defaultModel);
             // Rule shape/pattern validation ran in the RoutingStrategy 
constructor (the single
             // declaration-validation path). build() additionally checks rule 
keys against the
             // candidate set so a typo fails at the registration call site; 
descriptors that skip
@@ -237,7 +239,7 @@ public class ModelRouter extends Resource {
                 args.put("candidate_descriptions", new 
HashMap<>(descriptions));
             }
             if (defaultModel != null) {
-                args.put("default_model", defaultModel);
+                args.put(DEFAULT_MODEL_KEY, defaultModel);
             }
             args.put("fallback", fallback);
             args.put(STRATEGY_TYPE_KEY, strategy.getType().tag());
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingCandidateValidator.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingCandidateValidator.java
new file mode 100644
index 00000000..aa21b775
--- /dev/null
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingCandidateValidator.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.api.chat.model.routing;
+
+import org.apache.flink.annotation.Internal;
+
+import javax.annotation.Nullable;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Validates a model router's candidate declaration: at least one candidate, 
every name a non-empty
+ * String, no duplicates, and a default model (when set) that is one of them.
+ *
+ * <p>This is the single check behind every declaration path: {@link 
ModelRouter.Builder#build()} at
+ * the registration call site, plan construction for descriptors that never 
went through the
+ * builder, and the {@link ModelRouter} constructor itself. A typo therefore 
fails with one message
+ * wherever it is caught, rather than per routed request on the TaskManager, 
when the router is
+ * resolved.
+ *
+ * <p>Internal contract shared with the plan module; not a stable public API.
+ */
+@Internal
+public final class RoutingCandidateValidator {
+
+    private RoutingCandidateValidator() {}
+
+    /**
+     * Validate the candidate declaration.
+     *
+     * @param subject how to name the router in the message, e.g. {@code 
"ModelRouter"} or {@code
+     *     "Model router 'name'"}
+     * @param candidates the declared candidate names; the elements are 
checked, so a raw list from
+     *     a deserialized descriptor may be passed
+     * @param defaultModel the declared default model, or null for none
+     * @throws IllegalArgumentException when the declaration is invalid
+     */
+    public static void validate(
+            String subject, @Nullable List<?> candidates, @Nullable Object 
defaultModel) {
+        if (candidates == null || candidates.isEmpty()) {
+            throw new IllegalArgumentException(subject + " requires at least 
one candidate.");
+        }
+        Set<Object> uniqueNames = new LinkedHashSet<>();
+        for (Object name : candidates) {
+            if (!(name instanceof String) || ((String) name).isEmpty()) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "%s candidate names must be non-empty strings, 
got %s.",
+                                subject, name == null ? "null" : "'" + name + 
"'"));
+            }
+            if (!uniqueNames.add(name)) {
+                throw new IllegalArgumentException(
+                        String.format("%s candidate '%s' is duplicated.", 
subject, name));
+            }
+        }
+        if (defaultModel != null && !uniqueNames.contains(defaultModel)) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "%s default model '%s' is not one of the 
candidates %s.",
+                            subject, defaultModel, candidates));
+        }
+    }
+}
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/chat/model/routing/RoutingTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/chat/model/routing/RoutingTest.java
index 4dbfe234..cce0ed06 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/chat/model/routing/RoutingTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/chat/model/routing/RoutingTest.java
@@ -141,17 +141,39 @@ class RoutingTest {
 
     @Test
     void defaultModelMustBeCandidate() {
+        // A descriptor built outside the builder (e.g. deserialized) meets 
the same default-model
+        // check in the constructor; going through build() would throw before 
the constructor runs.
         assertThrows(
                 IllegalArgumentException.class,
                 () ->
                         new ModelRouter(
-                                ModelRouter.of("small", "big")
-                                        .strategy(Strategies.rules(Map.of()))
-                                        .defaultModel("huge")
-                                        .build(),
+                                new ResourceDescriptor(
+                                        ModelRouter.class.getName(), 
routerArgs("huge")),
                                 null));
     }
 
+    @Test
+    void routerConstructionRejectsNonStringDefaultModel() {
+        Map<String, Object> args = routerArgs(null);
+        args.put(ModelRouter.DEFAULT_MODEL_KEY, 123);
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new ModelRouter(
+                                new 
ResourceDescriptor(ModelRouter.class.getName(), args), null));
+    }
+
+    private static Map<String, Object> routerArgs(String defaultModel) {
+        Map<String, Object> args = new HashMap<>();
+        args.put(ModelRouter.CANDIDATES_KEY, List.of("small", "big"));
+        args.put(ModelRouter.STRATEGY_TYPE_KEY, "rule_based");
+        args.put(ModelRouter.STRATEGY_ARGS_KEY, 
Map.of(RoutingStrategy.ARG_RULES, Map.of()));
+        if (defaultModel != null) {
+            args.put(ModelRouter.DEFAULT_MODEL_KEY, defaultModel);
+        }
+        return args;
+    }
+
     @Test
     void modelRoutingEventRequestIdSurvivesStringForm() {
         // Simulate an EventLog JSON round-trip where request_id came back as 
a String.
@@ -457,4 +479,64 @@ class RoutingTest {
         // The copy re-normalizes through the constructor, so strategies see 
an empty list.
         assertEquals(0, ctx.getMessages().get(0).getToolCalls().size());
     }
+
+    /**
+     * The builder is the registration call site: a duplicate candidate, a 
blank one, or a default
+     * model that is not a candidate must fail here, not in the router 
constructor on the
+     * TaskManager (the constructor runs when the router is resolved, per 
routed request, before the
+     * durable routing call).
+     */
+    @Test
+    void builderRejectsDuplicateCandidate() {
+        IllegalArgumentException e =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () ->
+                                ModelRouter.of("small", "small")
+                                        .strategy(Strategies.rules(Map.of()))
+                                        .build());
+        assertTrue(e.getMessage().contains("duplicated"), e.getMessage());
+    }
+
+    @Test
+    void builderRejectsDefaultModelThatIsNotACandidate() {
+        IllegalArgumentException e =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () ->
+                                ModelRouter.of("small", "big")
+                                        .strategy(Strategies.rules(Map.of()))
+                                        .defaultModel("huge")
+                                        .build());
+        assertTrue(e.getMessage().contains("huge"), e.getMessage());
+    }
+
+    @Test
+    void builderRequiresAtLeastOneCandidate() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> 
ModelRouter.of().strategy(Strategies.rules(Map.of())).build());
+    }
+
+    @Test
+    void builderRejectsBlankCandidateName() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ModelRouter.of("small", 
"").strategy(Strategies.rules(Map.of())).build());
+    }
+
+    @Test
+    void routerConstructionRejectsBlankCandidateName() {
+        // A descriptor built outside the builder (e.g. deserialized) meets 
the same candidate
+        // check in the constructor.
+        Map<String, Object> args = new HashMap<>();
+        args.put(ModelRouter.CANDIDATES_KEY, List.of("small", ""));
+        args.put(ModelRouter.STRATEGY_TYPE_KEY, "rule_based");
+        args.put(ModelRouter.STRATEGY_ARGS_KEY, 
Map.of(RoutingStrategy.ARG_RULES, Map.of()));
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new ModelRouter(
+                                new 
ResourceDescriptor(ModelRouter.class.getName(), args), null));
+    }
 }
diff --git a/docs/content/docs/development/model_routing.md 
b/docs/content/docs/development/model_routing.md
index b286c20f..145211a9 100644
--- a/docs/content/docs/development/model_routing.md
+++ b/docs/content/docs/development/model_routing.md
@@ -118,12 +118,12 @@ Resources can equally be registered on the execution 
environment with `agentsEnv
 
 | Method | Description |
 |--------|-------------|
-| `of(String... candidates)` | Start a router over the given chat model names. 
|
+| `of(String... candidates)` | Start a router over the given chat model names. 
At least one, each non-empty and unique. |
 | `strategy(RoutingStrategy)` | Required. One of the `Strategies` factories 
below. |
 | `describe(candidate, description)` | Describe a candidate. Descriptions are 
the criteria the [LLM judge](#llm-judge) reads. The name must be a candidate. |
 | `defaultModel(String)` | Where the router lands when the strategy abstains. 
Optional; without it the first candidate is the default. Must be a candidate. |
 | `fallback(boolean)` | Try the remaining candidates, in declaration order, 
after the selected model fails. Off by default. |
-| `build()` | Produces the `ResourceDescriptor` to register. |
+| `build()` | Produces the `ResourceDescriptor` to register. Fails on a 
duplicate or empty candidate, a default model that is not a candidate, or a 
rule key that is not a candidate. |
 
 ## Routing Strategies
 
diff --git a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java 
b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
index 06f94fbe..54587434 100644
--- a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
+++ b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
@@ -32,6 +32,7 @@ import org.apache.flink.agents.api.annotation.Tool;
 import org.apache.flink.agents.api.annotation.VectorStore;
 import org.apache.flink.agents.api.chat.model.routing.CustomRoutingExecutor;
 import org.apache.flink.agents.api.chat.model.routing.ModelRouter;
+import 
org.apache.flink.agents.api.chat.model.routing.RoutingCandidateValidator;
 import org.apache.flink.agents.api.chat.model.routing.RoutingStrategy;
 import org.apache.flink.agents.api.chat.model.routing.RoutingStrategyType;
 import org.apache.flink.agents.api.function.JavaFunctionUtils;
@@ -816,6 +817,13 @@ public class AgentPlan implements Serializable {
             // shape guard applies to all of them — not only where the rule 
keys are checked.
             Object candidates = 
descriptor.getArgument(ModelRouter.CANDIDATES_KEY);
             validateCandidatesShape(provider.getName(), candidates);
+            // The constructor's own candidate rules (non-empty, no 
duplicates, default model is a
+            // candidate), applied here so a descriptor that skipped the 
builder fails at plan
+            // construction rather than per routed request on the TaskManager.
+            RoutingCandidateValidator.validate(
+                    String.format("Model router '%s'", provider.getName()),
+                    (List<?>) candidates,
+                    descriptor.getArgument(ModelRouter.DEFAULT_MODEL_KEY));
             switch (strategy.getType()) {
                 case LLM_JUDGE:
                     validateJudge(provider.getName(), strategy, chatModels);
@@ -836,15 +844,15 @@ public class AgentPlan implements Serializable {
      * Rule declarations are static constraints like the judge checks above: 
the fluent builder
      * rejects a bad one at build(), but a descriptor read back from a plan 
(deserialized or
      * hand-built) never went through the builder. Without this arm they would 
surface only per
-     * record at request time — inside the durable call — where the IGNORE 
error policy silently
+     * record at request time — when the router is resolved — where the IGNORE 
error policy silently
      * drops every matching record. Rule shape, value types and pattern 
validity were already
      * enforced by the {@link RoutingStrategy} constructor (regardless of the 
'candidates' shape);
      * the key-vs-candidate check here mirrors build().
      */
     /**
      * Fail here, not per record: the router constructor's unchecked read 
would turn a mis-shaped
-     * 'candidates' value into a raw ClassCastException inside the durable 
call. A missing argument
-     * is left to the constructor's own message ("requires at least one 
candidate").
+     * 'candidates' value into a raw ClassCastException when the router is 
resolved. A missing or
+     * empty list is rejected next by {@link RoutingCandidateValidator}.
      */
     private static void validateCandidatesShape(String routerName, Object 
candidates) {
         if (candidates != null && !(candidates instanceof List)) {
@@ -860,9 +868,6 @@ public class AgentPlan implements Serializable {
 
     private static void validateRuleKeys(
             String routerName, RoutingStrategy strategy, Object candidates) {
-        if (candidates == null) {
-            return;
-        }
         Object rules = strategy.getArguments().get(RoutingStrategy.ARG_RULES);
         if (!(rules instanceof Map)) {
             return;
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanLlmJudgeValidationTest.java
 
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanLlmJudgeValidationTest.java
index 7dd1844e..25eb3f2d 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanLlmJudgeValidationTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanLlmJudgeValidationTest.java
@@ -356,7 +356,7 @@ public class AgentPlanLlmJudgeValidationTest {
     /**
      * Mis-shaped 'candidates' with VALID rules also fails at plan 
construction (review): the router
      * constructor's unchecked read would otherwise turn it into a raw 
per-record ClassCastException
-     * inside the durable call.
+     * when the router is resolved on the TaskManager.
      */
     @Test
     void misShapedCandidatesFailAtPlanConstruction() {
@@ -397,4 +397,45 @@ public class AgentPlanLlmJudgeValidationTest {
     void registeredJudgeModelPassesValidation() {
         assertThatCode(() -> new AgentPlan(Map.of(), 
providers(true))).doesNotThrowAnyException();
     }
+
+    /**
+     * A default model that is not a candidate is a static declaration error 
like a typo'd rule key:
+     * the builder catches it, but a descriptor-built plan only met it in the 
router constructor —
+     * when the router is resolved on the TaskManager, per routed request, 
where IGNORE drops every
+     * record.
+     */
+    @Test
+    void defaultModelNamingNonCandidateFailsAtPlanConstruction() {
+        Map<ResourceType, Map<String, ResourceProvider>> providers = 
providers(true);
+        Map<String, Object> args = ruleBasedRouterArgs(List.of("small", 
"big"), Map.of());
+        args.put("default_model", "huge");
+        setRouter(providers, new 
ResourceDescriptor(ModelRouter.class.getName(), args));
+        assertThatThrownBy(() -> new AgentPlan(Map.of(), providers))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("default model")
+                .hasMessageContaining("huge")
+                .hasMessageContaining("router");
+    }
+
+    @Test
+    void duplicateCandidateFailsAtPlanConstruction() {
+        Map<ResourceType, Map<String, ResourceProvider>> providers =
+                ruleBasedProviders(List.of("small", "small"), Map.of());
+        assertThatThrownBy(() -> new AgentPlan(Map.of(), providers))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("duplicated")
+                .hasMessageContaining("router");
+    }
+
+    @Test
+    void emptyCandidateListFailsAtPlanConstruction() {
+        Map<ResourceType, Map<String, ResourceProvider>> providers = 
providers(true);
+        Map<String, Object> args = ruleBasedRouterArgs(List.of(), Map.of());
+        args.remove("default_model");
+        setRouter(providers, new 
ResourceDescriptor(ModelRouter.class.getName(), args));
+        assertThatThrownBy(() -> new AgentPlan(Map.of(), providers))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("candidate")
+                .hasMessageContaining("router");
+    }
 }
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanRoutingBackstopTest.java
 
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanRoutingBackstopTest.java
index c3a6744a..a2a07913 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanRoutingBackstopTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanRoutingBackstopTest.java
@@ -24,6 +24,7 @@ import org.apache.flink.agents.api.resource.ResourceType;
 import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -38,10 +39,18 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 class AgentPlanRoutingBackstopTest {
 
     private static ResourceDescriptor descriptor(String clazz) {
-        // Router descriptors must declare a strategy (validated at plan 
construction), so the
-        // hand-built minimal descriptor carries an empty rules strategy.
+        // Router descriptors must declare a strategy and at least one 
candidate (both validated
+        // at plan construction), so the hand-built minimal descriptor carries 
an empty rules
+        // strategy over one candidate.
         return new ResourceDescriptor(
-                clazz, Map.of("strategy_type", "rule_based", "strategy_args", 
Map.of()));
+                clazz,
+                Map.of(
+                        "candidates",
+                        List.of("small"),
+                        "strategy_type",
+                        "rule_based",
+                        "strategy_args",
+                        Map.of()));
     }
 
     @Test

Reply via email to