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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ The format is intentionally lightweight and human-readable. Group entries by rel

- Added richer client usage reporting in `GET /api/stats` and the dashboard, including per-client tokens, failures, success rate, and aggregate client totals

### Changed

- Tightened `static` and `heuristic` match semantics so combined fields now behave as cumulative constraints unless `any:` is used explicitly

## v1.0.0 - 2026-03-15

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ The current chat path is:
6. optional LLM classifier
7. fallback chain if the chosen provider fails

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.

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
46 changes: 27 additions & 19 deletions foundrygate/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,33 +840,34 @@ def _match_static(self, match: dict, ctx: _RoutingContext) -> bool:
if "any" in match:
return any(self._match_static(sub, ctx) for sub in match["any"])

matched_any = False

# model_requested
if "model_requested" in match:
matched_any = True
patterns = match["model_requested"]
if isinstance(patterns, str):
patterns = [patterns]
if any(p in ctx.model_requested for p in patterns):
return True
if match.keys() == {"model_requested"}:
if not any(p in ctx.model_requested for p in patterns):
return False

# system_prompt_contains
if "system_prompt_contains" in match:
matched_any = True
keywords = match["system_prompt_contains"]
lower_sys = ctx.system_prompt.lower()
if any(kw.lower() in lower_sys for kw in keywords):
return True
if match.keys() == {"system_prompt_contains"}:
if not any(kw.lower() in lower_sys for kw in keywords):
return False

# header_contains
if "header_contains" in match:
matched_any = True
for header_name, patterns in match["header_contains"].items():
header_val = ctx.headers.get(header_name, "").lower()
if any(p.lower() in header_val for p in patterns):
return True
if not any(p.lower() in header_val for p in patterns):
return False

return False
return matched_any

# ── Layer 2: Heuristic Rules ───────────────────────────────

Expand Down Expand Up @@ -896,23 +897,29 @@ def _match_heuristic(self, match: dict, ctx: _RoutingContext) -> bool:
if match.get("fallthrough"):
return True

matched_any = False

# has_tools
if "has_tools" in match:
if match["has_tools"] == ctx.has_tools:
return True
return False
matched_any = True
if match["has_tools"] != ctx.has_tools:
return False

# estimated_tokens
if "estimated_tokens" in match:
matched_any = True
tok_match = match["estimated_tokens"]
if "less_than" in tok_match and ctx.total_tokens < tok_match["less_than"]:
return True
if "greater_than" in tok_match and ctx.total_tokens > tok_match["greater_than"]:
return True
return False
token_ok = True
if "less_than" in tok_match:
token_ok = token_ok and ctx.total_tokens < tok_match["less_than"]
if "greater_than" in tok_match:
token_ok = token_ok and ctx.total_tokens > tok_match["greater_than"]
if not token_ok:
return False

# message_keywords
if "message_keywords" in match:
matched_any = True
kw_cfg = match["message_keywords"]
keywords = kw_cfg.get("any_of", [])
min_matches = kw_cfg.get("min_matches", 1)
Expand All @@ -923,9 +930,10 @@ def _match_heuristic(self, match: dict, ctx: _RoutingContext) -> bool:
search_text = ctx.last_user_message.lower()
hit_count = sum(1 for kw in keywords if kw.lower() in search_text)

return hit_count >= min_matches
if hit_count < min_matches:
return False

return False
return matched_any

# ── Layer 3: LLM Classifier ────────────────────────────────

Expand Down
54 changes: 54 additions & 0 deletions tests/test_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ async def test_subagent_header(self, router):
assert d.provider_name == "deepseek-chat"
assert d.rule_name == "subagent"

def test_static_match_requires_all_fields_by_default(self, router):
ctx = types.SimpleNamespace(
model_requested="auto",
system_prompt="delegated task planner",
headers={"x-openclaw-source": "primary-agent"},
)
assert (
router._match_static( # noqa: SLF001
{
"system_prompt_contains": ["delegated task"],
"header_contains": {"x-openclaw-source": ["subagent"]},
},
ctx,
)
is False
)
assert (
router._match_static( # noqa: SLF001
{
"system_prompt_contains": ["delegated task"],
"header_contains": {"x-openclaw-source": ["primary-agent"]},
},
ctx,
)
is True
)


# ── Heuristic routing ─────────────────────────────────────────

Expand Down Expand Up @@ -216,6 +243,33 @@ async def test_system_prompt_not_scored(self, router):
# Should NOT be deepseek-reasoner despite system prompt keywords
assert d.provider_name != "deepseek-reasoner"

def test_heuristic_match_requires_all_fields_by_default(self, router):
ctx = types.SimpleNamespace(
has_tools=True,
total_tokens=90,
last_user_message="search files and summarize the result",
)
assert (
router._match_heuristic( # noqa: SLF001
{
"has_tools": True,
"estimated_tokens": {"greater_than": 100},
},
ctx,
)
is False
)
assert (
router._match_heuristic( # noqa: SLF001
{
"has_tools": True,
"estimated_tokens": {"less_than": 100},
},
ctx,
)
is True
)


# ── Health fallback ────────────────────────────────────────────

Expand Down
Loading