Router/2385 collection.router recipe dispatch#2558
Conversation
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks for pushing this forward — the overall direction looks good, especially parsing the router policy once and wiring the dispatch through the existing request paths.
I think this needs a few changes before it is merge-ready:
-
CI is currently failing in the new test_021z_router_collection_chat_dispatch test. The test registers a collection.router without version, but the parser requires collection.version and only accepts "1". This should be resolved either by making "version": "1" mandatory everywhere, including tests/examples/UI exports, or by defaulting missing versions to "1" server-side. (should not be the current self-hosted-runner issue)
-
Even if the test adds
version, the policy seems likely to break after persistence/cache rebuild. register_user_model() only preserves keys from USER_DEFINED_MODEL_PROPS, and version is not currently included there. build_cache() also reconstructs the parser document only from recipe, components, and routing, so the parsed route_policy will still fail becauseversionis missing. We should persist/restore version, or explicitly inject the supported default version before parsing. -
The current “fail open” path does not actually leave the request untouched. route_collection_request() returns the collection name when no parsed policy exists, but the callers still assign that return value back to request_json["model"]. I think this should return an optional/result type so the model field is only rewritten when routing actually succeeds.
-
/load currently treats collection.router differently from collection.omni: only omni collections are excluded from the normal backend-load path. Since router collections are virtual as well, /load should either load the relevant components, be a no-op with success, or return a clear error — but it should not fall through to
router_->load_model()as a normal backend recipe. -
The PR claims coverage for /chat/completions, /completions, and /responses, but the integration coverage currently only exercises /chat/completions. I’d like to see endpoint coverage for /completions, /responses including streaming, persistence/cache rebuild after /pull, broken/missing routing policy behavior, and /load on a router collection.
Once those are addressed, this should be in much better shape. The architecture is promising, but the current version/persistence/load-path issues make it risky to merge as-is.
|
Thanks for the review — addressed all five:
It's worth noting for the future that we should probably rethink how model load limits work with the router: we can expect multiple small models that should remain loaded all the time, which the current LRU eviction doesn't really account for — but that's a question for later, once we see some real usage examples. |
Resolve conflicts from #2519 (parser/store) and #2548 (classifier services) landing on main after this branch forked: - routing_classifier_services.{h,cpp}: keep main's reviewed parse/validation body (validate_score_range, try_extract_chat_text) and re-add this branch's build_route_context + collect_text_from_content/content_has_image helpers, which #2385 dispatch needs. - routing_policy_store.{h,cpp}, test_routing_policy_store.cpp, test_routing_classifier_services.cpp: take main (strict superset — C++20 TODO, case-insensitive .json, added tests). - model_manager.cpp USER_DEFINED_MODEL_PROPS: keep "version" (router policies carry a required root version) on top of main's "routing". - server.cpp download-skip: is_model_collection_recipe (omni OR router) — a collection.router has no checkpoint and must skip the generic HF download; main's is_omni_collection_recipe would wrongly route it through HF. lemond builds; routing parser/engine/deterministic/classifier-services/ collection-validation unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Merged latest How each conflict was resolved (8 files)
Heads-up: Validation
One design note (not blocking, your call)
|
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks for the follow-up and for merging latest main into this branch. I re-reviewed the current head, also in the context of #2519 and #2548.
And I still think this needs one more pass before merge:
-
Router collection JSON round-trip still seems incomplete.
The parser intentionally requires root "version": "1", and this PR now persists/rebuilds that field internally. However, model_info_to_json() surfacesroutingfor router collections but does not surfaceversion. Since #2519 introduced the import/export round-trip plumbing for router policies, exporting a router collection from/models/{id}or/models?show_all=truecan lose the required schema version and then fail on re-import. Please emit version for collection.router models, probably frominfo.extras["version"], next torouting, and add a regression test that the exported router collection JSON includes "version": "1" and can be submitted back through /pull. -
The /responses RouteContext extraction is still under-covered for typed content/images.
The PR description says typed-parts content and has_images are handled, but the current helpers only read content parts with type == "text", only detect images with type == "image_url", and the input branch does not set has_images at all. The new /responses tests use plain string input, so they do not exercise typed parts or image-bearing input. Please either support the typed input parts Lemonade expects to accept on /responses and add coverage, or narrow the PR claim/scope.
Non-blocking design note: build_route_context() currently lives in routing_classifier_services, but from the #2548 layering discussion this feels more like server dispatch/request-shape code than classifier-service wiring. I would prefer relocating it eventually, but I do not think that needs to block this PR if the two behavior/coverage points above are addressed.
After those are fixed, I’d be comfortable approving this.
|
Comment 1 — version lost on export round-trip: server.cpp now emits Comment 2 — typed content/images under-covered on
|
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks for the quick follow-up. I re-reviewed the current head.
But there seems to be one remaining correctness issue before I would approve:
build_route_context() still does not quite match the PR’s stated “last user message text” behavior for /responses. The chat path scans backward and selects the last role == "user" message, but the Responses input array path currently iterates over every item and appends text from any object with content, regardless of role. That means prior assistant/developer/system content can influence routing if it contains a keyword or other matched text, even when the latest user input should route differently.
Could we adjust the Responses path to mirror the chat behavior where possible? For example: scan the input array from the end for the last object with role == "user" and content, use only that content for ctx.input, while still detecting images as appropriate. If there are bare content parts without roles, the current fallback behavior seems fine.
A small unit regression would be enough here, e.g. a Responses input array where an earlier assistant message contains "code" but the last user message does not, and ctx.input should only reflect the last user message..
Good catch — fixed. The |
* 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 * feat(router): wire classifier services to router calls * Fixed parse_scores_payload to only check message.content not metadata * Initial implementation * Addressed five issues raised * Addressed comments * Addressed build_route_context comment --------- Co-authored-by: Eddie Richter <eddier@amd.com> Co-authored-by: ramkrishna2910 <ramkrishna2910@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(this builds on #2519 and #2548 so only the final two commits 99b5f30 and 9707009 are meaningful)
Wire up request-time dispatch for
collection.routermodels. Addressing a router collection by name on/chat/completions,/completions, or/responsesnow runs its routing engine, selects a candidate, and dispatches the request to it — the recipe itself is the trigger (no"auto"model, no separate/v1/routeendpoint). Mirrors the existingcollection.omnidispatch wiring.Dispatch
Server::route_collection_request()runs the collection's routing engine and returns the selected candidate;apply_router_collection_dispatch()rewrites the request's"model"field in place (no-op for non-router models).handle_chat_completionsruns it inline;handle_completionsandhandle_responsescallapply_router_collection_dispatch()before the usual load/forward path.handle_responsesnow re-serializes the (possibly rewritten) body so the selection reaches the backend on the streaming path.RouteContext extraction
build_route_context()translates a chat/completions, completions, or responses body into a backend-agnosticRouteContext: last user message text (string or typed-parts content),has_tools,has_images, char count, and stringmetadata.make_chat_request(),collect_text_from_content(),content_has_image().Policy parsed once
ModelInfoasshared_ptr<const RoutePolicy>, so dispatch just reads it instead of reconstructing and re-parsing per request. Null for non-router recipes; parse failures log and fail open.Tests
test_021z_router_collection_chat_dispatch: pulls acollection.router, addresses it by name, and asserts the response is a real completion from the engine-selected candidate (not the router alias).Notes
RoutingPolicyEngineis still assembled per request because itsClassifierServicesbind the liveRouter, which only exists on the Server side; engine construction is cheap next to the classifier workroute()triggers.