Skip to content

Commit 3cebf6b

Browse files
Router/2383 policy parser hot reload (#2519)
* feat(router): add policy parser and hot-reload store * feat(router): preserve collection routing metadata * test(router): cover policy loading in CI * fix(router): align pull validation with policy compilation * fix(router): reject duplicate policy model names * fix(router): harden policy store file handling
1 parent a174302 commit 3cebf6b

20 files changed

Lines changed: 1562 additions & 41 deletions

.github/workflows/cpp_server_build_test_release.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,11 @@ jobs:
282282
cmake --build --preset default --target test_routing_policy_semantic
283283
cmake --build --preset default --target test_routing_policy_deterministic
284284
cmake --build --preset default --target test_routing_policy_engine
285+
cmake --build --preset default --target test_routing_policy_parser
286+
cmake --build --preset default --target test_routing_policy_store
287+
cmake --build --preset default --target test_model_manager_collection_validation
285288
cd build
286-
ctest --output-on-failure -R "DirectoryWatcher|LatestVersionFallback|RoutingPolicy(Contract|Evaluator|Registry|Semantic|Deterministic|Engine)Test"
289+
ctest --output-on-failure -R "DirectoryWatcher|LatestVersionFallback|RoutingPolicy(Contract|Evaluator|Registry|Semantic|Deterministic|Engine|Parser|Store)Test|ModelManagerCollectionValidationTest"
287290
288291
- name: Upload .deb package
289292
uses: actions/upload-artifact@v7
@@ -588,8 +591,11 @@ jobs:
588591
cmake --build --preset default --target test_routing_policy_semantic
589592
cmake --build --preset default --target test_routing_policy_deterministic
590593
cmake --build --preset default --target test_routing_policy_engine
594+
cmake --build --preset default --target test_routing_policy_parser
595+
cmake --build --preset default --target test_routing_policy_store
596+
cmake --build --preset default --target test_model_manager_collection_validation
591597
cd build
592-
ctest --output-on-failure -R "DirectoryWatcher|LatestVersionFallback|RoutingPolicy(Contract|Evaluator|Registry|Semantic|Deterministic|Engine)Test"
598+
ctest --output-on-failure -R "DirectoryWatcher|LatestVersionFallback|RoutingPolicy(Contract|Evaluator|Registry|Semantic|Deterministic|Engine|Parser|Store)Test|ModelManagerCollectionValidationTest"
593599
594600
- name: Upload .pkg package
595601
if: steps.check_signing.outputs.has_signing == 'true'

CMakeLists.txt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,8 @@ set(SOURCES_CORE
630630
src/cpp/server/system_info.cpp
631631
src/cpp/server/recipe_options.cpp
632632
src/cpp/server/routing_policy.cpp
633+
src/cpp/server/routing_policy_parser.cpp
634+
src/cpp/server/routing_policy_store.cpp
633635
src/cpp/server/runtime_config.cpp
634636
src/cpp/server/telemetry.cpp
635637
src/cpp/server/logging_config.cpp
@@ -2025,6 +2027,78 @@ if(EXISTS "${_ROUTING_ENGINE_TEST_SRC}")
20252027
add_test(NAME RoutingPolicyEngineTest COMMAND test_routing_policy_engine)
20262028
endif()
20272029

2030+
# Routing policy parser (issue #2383): collection.router JSON -> RoutePolicy,
2031+
# schema/parser key parity, component resolution, and validation errors.
2032+
set(_ROUTING_PARSER_TEST_SRC
2033+
"${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_routing_policy_parser.cpp"
2034+
)
2035+
if(EXISTS "${_ROUTING_PARSER_TEST_SRC}")
2036+
add_executable(test_routing_policy_parser
2037+
test/cpp/test_routing_policy_parser.cpp
2038+
src/cpp/server/routing_policy.cpp
2039+
src/cpp/server/routing_policy_parser.cpp
2040+
)
2041+
target_include_directories(test_routing_policy_parser PRIVATE
2042+
${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/include
2043+
${CMAKE_CURRENT_SOURCE_DIR}/test/cpp
2044+
${CMAKE_CURRENT_BINARY_DIR}/include
2045+
)
2046+
target_link_libraries(test_routing_policy_parser PRIVATE nlohmann_json::nlohmann_json)
2047+
target_compile_definitions(test_routing_policy_parser PRIVATE
2048+
ROUTING_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/fixtures/routing"
2049+
ROUTING_SCHEMA_FILE="${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/resources/schemas/route_policy.schema.json"
2050+
)
2051+
2052+
include(CTest)
2053+
add_test(NAME RoutingPolicyParserTest COMMAND test_routing_policy_parser)
2054+
endif()
2055+
2056+
# Routing policy store (issue #2383): directory-backed policy loading and
2057+
# DirectoryWatcher-triggered shared_ptr snapshot swaps.
2058+
set(_ROUTING_STORE_TEST_SRC
2059+
"${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_routing_policy_store.cpp"
2060+
)
2061+
if(EXISTS "${_ROUTING_STORE_TEST_SRC}")
2062+
add_executable(test_routing_policy_store
2063+
test/cpp/test_routing_policy_store.cpp
2064+
src/cpp/server/routing_policy.cpp
2065+
src/cpp/server/routing_policy_parser.cpp
2066+
src/cpp/server/routing_policy_store.cpp
2067+
src/cpp/server/directory_watcher.cpp
2068+
)
2069+
target_include_directories(test_routing_policy_store PRIVATE
2070+
${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/include
2071+
${CMAKE_CURRENT_SOURCE_DIR}/test/cpp
2072+
${CMAKE_CURRENT_BINARY_DIR}/include
2073+
)
2074+
target_link_libraries(test_routing_policy_store PRIVATE nlohmann_json::nlohmann_json)
2075+
find_package(Threads REQUIRED)
2076+
target_link_libraries(test_routing_policy_store PRIVATE Threads::Threads)
2077+
target_compile_definitions(test_routing_policy_store PRIVATE
2078+
ROUTING_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/fixtures/routing"
2079+
)
2080+
2081+
include(CTest)
2082+
add_test(NAME RoutingPolicyStoreTest COMMAND test_routing_policy_store)
2083+
endif()
2084+
2085+
# ModelManager collection validation for collection.router registration:
2086+
# /pull-style validation rejects malformed routing and preserves the routing
2087+
# block in ModelInfo::extras after user-model registration.
2088+
set(_MODEL_MANAGER_COLLECTION_VALIDATION_TEST_SRC
2089+
"${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_model_manager_collection_validation.cpp"
2090+
)
2091+
if(EXISTS "${_MODEL_MANAGER_COLLECTION_VALIDATION_TEST_SRC}")
2092+
add_executable(test_model_manager_collection_validation
2093+
test/cpp/test_model_manager_collection_validation.cpp
2094+
)
2095+
target_link_libraries(test_model_manager_collection_validation PRIVATE lemonade-server-core)
2096+
add_dependencies(test_model_manager_collection_validation copy_resources)
2097+
2098+
include(CTest)
2099+
add_test(NAME ModelManagerCollectionValidationTest COMMAND test_model_manager_collection_validation)
2100+
endif()
2101+
20282102
# Auto-tune: GGUF array storage, scalar derivation, weighted KV cache computation.
20292103
# Covers head_count_kv_per_layer, sliding_window_pattern, SWA precise weighted sum,
20302104
# full_attention_interval exact count, and scalar fallback paths.

src/app/src/renderer/utils/modelData.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isCollectionRecipe } from './recipeNames';
1+
import { isModelCollectionRecipe } from './recipeNames';
22

33
export const USER_MODEL_PREFIX = 'user.';
44

@@ -36,6 +36,9 @@ export interface ModelInfo {
3636
// global default in toolDefinitions.json when set. Keeps {tool_list} and
3737
// {tool_guidance} placeholders so runtime substitution still works.
3838
system_prompt?: string;
39+
// collection.router policies. Kept opaque in the general model catalog; router
40+
// authoring tools validate against the routing schema.
41+
routing?: unknown;
3942
[key: string]: unknown;
4043
}
4144

@@ -76,7 +79,7 @@ const normalizeModelInfo = (info: unknown): ModelInfo | null => {
7679
const checkpoint = typeof info['checkpoint'] === 'string' ? info['checkpoint'] : '';
7780
const recipe = typeof info['recipe'] === 'string' ? info['recipe'] : '';
7881

79-
if (!recipe || (!checkpoint && !isCollectionRecipe(recipe))) {
82+
if (!recipe || (!checkpoint && !isModelCollectionRecipe(recipe))) {
8083
return null;
8184
}
8285

@@ -142,6 +145,10 @@ const normalizeModelInfo = (info: unknown): ModelInfo | null => {
142145
normalized.system_prompt = systemPrompt;
143146
}
144147

148+
if (isRecord(info['routing'])) {
149+
normalized.routing = info['routing'];
150+
}
151+
145152
const vision = info['vision'];
146153
if (typeof vision === 'boolean') {
147154
normalized.vision = vision;
@@ -234,6 +241,10 @@ const fetchBuiltInModelsFromAPI = async (): Promise<ModelsData> => {
234241
modelInfo.system_prompt = model.system_prompt;
235242
}
236243

244+
if (model.routing && typeof model.routing === 'object' && !Array.isArray(model.routing)) {
245+
modelInfo.routing = model.routing;
246+
}
247+
237248
// cloud_provider distinguishes per-provider buckets in the Model
238249
// Manager grouping (recipe="cloud" alone collapses all providers
239250
// into a single sub-heading).
@@ -289,6 +300,7 @@ const EXPORT_KNOWN_KEYS = new Set([
289300
'labels',
290301
'recipe',
291302
'recipe_options',
303+
'routing',
292304
'size',
293305
'system_prompt',
294306
]);
@@ -323,7 +335,7 @@ export const normalizeModelExportPayload = (
323335
const name = typeof payload.model_name === 'string' && payload.model_name ? payload.model_name : fallbackId;
324336
payload.model_name = name.startsWith(USER_MODEL_PREFIX) ? name : `${USER_MODEL_PREFIX}${name}`;
325337

326-
if (isCollectionRecipe(typeof payload.recipe === 'string' ? payload.recipe : undefined)) {
338+
if (isModelCollectionRecipe(typeof payload.recipe === 'string' ? payload.recipe : undefined)) {
327339
// Normalize each embedded component with the same transform. Components
328340
// are leaf models: drop their (empty) collection fields and keep bare
329341
// names — the server decides `user.` prefixing when registering them.

src/app/src/renderer/utils/recipeNames.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
export const COLLECTION_OMNI_MODEL_RECIPE = 'collection.omni';
2+
export const COLLECTION_ROUTER_MODEL_RECIPE = 'collection.router';
23

34
export const isCollectionRecipe = (recipe?: string): boolean => {
45
return recipe === COLLECTION_OMNI_MODEL_RECIPE;
56
};
67

8+
export const isModelCollectionRecipe = (recipe?: string): boolean => {
9+
return recipe === COLLECTION_OMNI_MODEL_RECIPE || recipe === COLLECTION_ROUTER_MODEL_RECIPE;
10+
};
11+
712
// Recipe display names. Hardware-backend names (llamacpp, whispercpp, sd-cpp, …)
813
// are populated at runtime from /system-info's `recipes[].display_name`, which is
914
// generated from the C++ backend descriptors — the single source of truth. Only
@@ -12,6 +17,7 @@ export const isCollectionRecipe = (recipe?: string): boolean => {
1217
// no local support rows).
1318
export const RECIPE_DISPLAY_NAMES: Record<string, string> = {
1419
[COLLECTION_OMNI_MODEL_RECIPE]: 'Lemonade',
20+
[COLLECTION_ROUTER_MODEL_RECIPE]: 'Router',
1521
'cloud': 'Cloud',
1622
};
1723

src/cpp/cli/main.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ static bool has_manual_pull_options(const CliConfig& config) {
361361

362362
static int handle_pull_command(lemonade::LemonadeClient& client, const CliConfig& config) {
363363
if (has_manual_pull_options(config)) {
364-
if (lemon::is_collection_recipe(config.recipe)) {
364+
if (lemon::is_omni_collection_recipe(config.recipe)) {
365365
if (config.components.empty()) {
366366
std::cerr << "Error: omni pull requires --components MODEL [MODEL ...]."
367367
<< std::endl;
@@ -1271,7 +1271,7 @@ int main(int argc, char* argv[]) {
12711271
->type_name("TYPE CHECKPOINT")
12721272
->multi_option_policy(CLI::MultiOptionPolicy::TakeAll);
12731273
pull_cmd->add_option("--recipe", config.recipe,
1274-
"Recipe for the custom user.* model (e.g., llamacpp, flm, sd-cpp, whispercpp, collection.omni)")
1274+
"Recipe for the custom user.* model (e.g., llamacpp, flm, sd-cpp, whispercpp, collection.omni, collection.router)")
12751275
->group("Manual Configuration Options")
12761276
->type_name("RECIPE")
12771277
->default_val(config.recipe);

src/cpp/cli/recipe_import.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const std::vector<std::string> kKnownKeys = {
3030
"labels",
3131
"recipe",
3232
"recipe_options",
33+
"routing",
3334
"size",
3435
"system_prompt"
3536
};
@@ -244,7 +245,7 @@ bool validate_and_transform_model_json(nlohmann::json& model_data) {
244245
return false;
245246
}
246247

247-
bool is_collection = lemon::is_collection_recipe(model_data["recipe"].get<std::string>());
248+
bool is_collection = lemon::is_model_collection_recipe(model_data["recipe"].get<std::string>());
248249

249250
bool has_checkpoints = model_data.contains("checkpoints") && model_data["checkpoints"].is_object();
250251
bool has_checkpoint = model_data.contains("checkpoint") && model_data["checkpoint"].is_string();

src/cpp/include/lemon/model_types.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@
77
namespace lemon {
88

99
constexpr const char* COLLECTION_OMNI_MODEL_RECIPE = "collection.omni";
10+
constexpr const char* COLLECTION_ROUTER_MODEL_RECIPE = "collection.router";
1011

11-
inline bool is_collection_recipe(const std::string& recipe) {
12+
inline bool is_omni_collection_recipe(const std::string& recipe) {
1213
return recipe == COLLECTION_OMNI_MODEL_RECIPE;
1314
}
1415

16+
inline bool is_router_collection_recipe(const std::string& recipe) {
17+
return recipe == COLLECTION_ROUTER_MODEL_RECIPE;
18+
}
19+
20+
inline bool is_model_collection_recipe(const std::string& recipe) {
21+
return is_omni_collection_recipe(recipe) || is_router_collection_recipe(recipe);
22+
}
23+
1524
enum class ModelState {
1625
LOADING,
1726
READY,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#pragma once
2+
3+
#include "lemon/routing_policy.h"
4+
5+
#include <functional>
6+
#include <optional>
7+
#include <set>
8+
#include <string>
9+
#include <vector>
10+
11+
namespace lemon {
12+
13+
// Resolves a component name from collection JSON into the name the engine should
14+
// route to. Server integration can bind this to ModelManager::resolve_model_name;
15+
// pure parser tests use the identity resolver.
16+
using RoutingComponentResolver =
17+
std::function<std::optional<std::string>(const std::string& component)>;
18+
19+
struct RoutingPolicyParseOptions {
20+
RoutingComponentResolver resolve_component;
21+
bool require_declared_components = true;
22+
};
23+
24+
// Parser key registries. The parser rejects any key outside these sets; the
25+
// schema-parity test compares them to route_policy.schema.json so parser and
26+
// schema vocabulary cannot drift silently.
27+
const std::set<std::string>& routing_policy_root_keys();
28+
const std::set<std::string>& routing_block_keys();
29+
const std::set<std::string>& routing_router_keys();
30+
const std::set<std::string>& routing_classifier_keys();
31+
const std::set<std::string>& routing_rule_keys();
32+
const std::set<std::string>& routing_match_expr_keys();
33+
const std::set<std::string>& routing_metadata_match_keys();
34+
35+
// Parse a full collection.router document into engine-ready policy state.
36+
// Throws std::invalid_argument with a user-facing message on validation errors.
37+
RoutePolicy parse_route_policy_collection(
38+
const json& collection_json,
39+
const RoutingPolicyParseOptions& options = RoutingPolicyParseOptions{});
40+
41+
} // namespace lemon
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#pragma once
2+
3+
#include "lemon/directory_watcher.h"
4+
#include "lemon/routing_policy.h"
5+
#include "lemon/routing_policy_parser.h"
6+
7+
#include <map>
8+
#include <memory>
9+
#include <string>
10+
11+
namespace lemon {
12+
13+
// Directory-backed cache of compiled collection.router engines. A reload builds
14+
// a complete immutable snapshot and atomically swaps the shared_ptr, so readers
15+
// either see the old valid set or the new valid set.
16+
//
17+
// This is the module-level hot-reload implementation for #2383. It is not wired
18+
// into Server request dispatch yet: #2385 owns attaching a store/registry to the
19+
// live server lifecycle and using its engines to route OpenAI requests.
20+
class RoutingPolicyStore {
21+
public:
22+
struct Snapshot {
23+
std::map<std::string, std::shared_ptr<const RoutingPolicyEngine>> engines;
24+
std::map<std::string, std::string> errors;
25+
};
26+
27+
RoutingPolicyStore(std::string directory,
28+
ClassifierServices services,
29+
RoutingPolicyParseOptions parse_options = {});
30+
~RoutingPolicyStore();
31+
32+
std::shared_ptr<const Snapshot> reload();
33+
void start_watching();
34+
void stop_watching();
35+
36+
std::shared_ptr<const RoutingPolicyEngine> get_engine(const std::string& model_name) const;
37+
std::shared_ptr<const Snapshot> snapshot() const;
38+
39+
private:
40+
std::shared_ptr<const Snapshot> load_directory() const;
41+
42+
std::string directory_;
43+
ClassifierServices services_;
44+
RoutingPolicyParseOptions parse_options_;
45+
std::unique_ptr<DirectoryWatcher> watcher_;
46+
// TODO(C++20): replace std::atomic_load/store free functions with a
47+
// std::atomic<std::shared_ptr<const Snapshot>> member (deprecated in C++20).
48+
std::shared_ptr<const Snapshot> snapshot_;
49+
};
50+
51+
} // namespace lemon

src/cpp/server/hf_variants.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ nlohmann::json fetch_pull_variants(const std::string& checkpoint, bool& not_foun
323323
manifest = nlohmann::json();
324324
}
325325
bool valid_manifest = manifest.is_object() &&
326-
is_collection_recipe(manifest.value("recipe", std::string())) &&
326+
is_omni_collection_recipe(manifest.value("recipe", std::string())) &&
327327
manifest.contains("components") && manifest["components"].is_array() &&
328328
!manifest["components"].empty() &&
329329
manifest.contains("models") && manifest["models"].is_array();

0 commit comments

Comments
 (0)