Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions foundrygate/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,24 +435,33 @@ 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"}

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
Expand Down
108 changes: 108 additions & 0 deletions tests/test_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading