From edba1591dcb3c13791de5de59226b1221c4939c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Mon, 16 Mar 2026 02:23:43 +0100 Subject: [PATCH] feat(router): tighten policy match semantics --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 9 ++++ foundrygate/router.py | 21 +++++--- tests/test_policies.py | 108 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2944532..caf851e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel ### Changed - Tightened `static` and `heuristic` match semantics so combined fields now behave as cumulative constraints unless `any:` is used explicitly +- Tightened `policy` match semantics so `client_profile` acts as an additive constraint inside one rule instead of bypassing sibling static or heuristic fields ## v1.0.0 - 2026-03-15 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9364989..c3c8b70 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,6 +45,15 @@ The current chat path is: Within one `static` or `heuristic` match block, configured fields now behave as cumulative constraints. Use explicit `any:` only when you want OR behavior across subconditions. This keeps combined rules explainable and avoids accidental matches when only one of several intended constraints is present. +Policy matches follow the same discipline. `client_profile` is additive inside one policy match block, not a shortcut that bypasses the other configured fields. If one policy rule should match on either caller identity or a static/heuristic signal, express that explicitly with `any:`. + +In practice, the layers split into two categories: + +- hard decision layers: `policy`, `static`, and `heuristic` +- soft preference layers: `request hooks`, `client profiles`, and the optional `llm-classify` + +The hard layers should carry governance, routing intent, and deterministic behavior. The soft layers should only add provider preference or narrow the candidate set when no harder layer has already made the decision. + Before a candidate is accepted, FoundryGate also scores and validates route fit against provider metadata such as context window, input/output token limits, cache hints, locality, health, latency, and recent failure state. ## Provider layer diff --git a/foundrygate/router.py b/foundrygate/router.py index a10c631..24a5052 100644 --- a/foundrygate/router.py +++ b/foundrygate/router.py @@ -435,11 +435,16 @@ def _match_policy(self, match: dict, ctx: _RoutingContext) -> bool: return all(self._match_policy(sub, ctx) for sub in match["all"]) if "any" in match: return any(self._match_policy(sub, ctx) for sub in match["any"]) + + matched_any = False + if "client_profile" in match: + matched_any = True profiles = match["client_profile"] if isinstance(profiles, str): profiles = [profiles] - return ctx.client_profile in profiles + if ctx.client_profile not in profiles: + return False static_keys = {"model_requested", "system_prompt_contains", "header_contains", "any"} heuristic_keys = {"has_tools", "estimated_tokens", "message_keywords", "fallthrough"} @@ -447,12 +452,16 @@ def _match_policy(self, match: dict, ctx: _RoutingContext) -> bool: static_match = {k: match[k] for k in static_keys if k in match} heuristic_match = {k: match[k] for k in heuristic_keys if k in match} - if static_match and not self._match_static(static_match, ctx): - return False - if heuristic_match and not self._match_heuristic(heuristic_match, ctx): - return False + if static_match: + matched_any = True + if not self._match_static(static_match, ctx): + return False + if heuristic_match: + matched_any = True + if not self._match_heuristic(heuristic_match, ctx): + return False - return bool(static_match or heuristic_match) + return matched_any def _select_policy_provider( self, select: dict, ctx: _RoutingContext diff --git a/tests/test_policies.py b/tests/test_policies.py index a9ec944..2b73fbe 100644 --- a/tests/test_policies.py +++ b/tests/test_policies.py @@ -163,6 +163,114 @@ async def test_policy_falls_to_next_healthy_preferred_candidate(self, tmp_path): assert decision.layer == "policy" assert decision.provider_name == "tool-secondary" + @pytest.mark.asyncio + async def test_policy_client_profile_is_cumulative_with_other_fields(self, tmp_path): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + local-worker: + backend: openai-compat + base_url: "http://127.0.0.1:11434/v1" + api_key: "local" + model: "llama3" + tier: local + capabilities: + local: true + cloud-default: + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "cloud-chat" +routing_policies: + enabled: true + rules: + - name: local-tools-only + match: + client_profile: ["openclaw"] + has_tools: true + select: + capability_values: + local: true +fallback_chain: + - cloud-default +metrics: + enabled: false +""", + ) + ) + router = Router(cfg) + + without_tools = await router.route( + [{"role": "user", "content": "hello"}], + model_requested="auto", + client_profile="openclaw", + has_tools=False, + ) + with_tools = await router.route( + [{"role": "user", "content": "search files"}], + model_requested="auto", + client_profile="openclaw", + has_tools=True, + ) + + assert without_tools.layer != "policy" + assert with_tools.layer == "policy" + assert with_tools.rule_name == "local-tools-only" + assert with_tools.provider_name == "local-worker" + + def test_policy_any_can_mix_client_profile_and_static_conditions(self, tmp_path): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + local-worker: + backend: openai-compat + base_url: "http://127.0.0.1:11434/v1" + api_key: "local" + model: "llama3" + tier: local + capabilities: + local: true +fallback_chain: + - local-worker +metrics: + enabled: false +""", + ) + ) + router = Router(cfg) + ctx = types.SimpleNamespace( + client_profile="openclaw", + model_requested="auto", + system_prompt="", + headers={"x-foundrygate-client": "codex"}, + has_tools=False, + total_tokens=20, + last_user_message="hello", + ) + + assert ( + router._match_policy( # noqa: SLF001 + { + "any": [ + {"client_profile": ["cli"]}, + {"header_contains": {"x-foundrygate-client": ["codex"]}}, + ] + }, + ctx, + ) + is True + ) + class TestPolicyValidation: def test_policy_rejects_unknown_provider_reference(self, tmp_path):