diff --git a/CHANGELOG.md b/CHANGELOG.md index e69bde2..7f2f8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel - Added `foundrygate-onboarding-validate` so onboarding blockers can fail fast in local setup and CI-style validation flows - Added built-in OpenClaw, n8n, and CLI quickstart examples to the onboarding report and integration docs so client onboarding can stay copy/paste friendly - Added staged provider-rollout reporting and fallback/image readiness warnings so many-provider onboarding is easier to phase safely +- Added a client matrix to the onboarding report so profile match rules and routing intent are visible before traffic goes live ## v0.7.0 - 2026-03-12 diff --git a/README.md b/README.md index c814386..c0b29d7 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ $EDITOR .env ./scripts/foundrygate-onboarding-report ``` -The onboarding report now includes concrete OpenClaw, n8n, and CLI quickstart hints plus a staged provider-rollout view, so you can move from a generic health check to a real client and provider rollout path without leaving the terminal. +The onboarding report now includes concrete OpenClaw, n8n, and CLI quickstart hints, a staged provider-rollout view, and a client matrix, so you can move from a generic health check to a real client and provider rollout path without leaving the terminal. If you prefer the Linux service path instead of a manual Python run, jump to [Helper Scripts](#helper-scripts) and use `./scripts/foundrygate-install`. @@ -839,7 +839,7 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin | --- | --- | | `foundrygate-bootstrap` | Creates `.env` from `.env.example` if needed, creates a local state dir, and appends a safe local `FOUNDRYGATE_DB_PATH` if none is set | | `foundrygate-doctor` | Checks for config/env presence, writable DB path, at least one configured provider key, and optional local health endpoints | -| `foundrygate-onboarding-report` | Summarizes provider readiness, staged rollout readiness, client-profile coverage, routing layers, onboarding suggestions, and concrete OpenClaw/n8n/CLI quickstarts | +| `foundrygate-onboarding-report` | Summarizes provider readiness, staged rollout readiness, client-profile coverage, client match intent, routing layers, onboarding suggestions, and concrete OpenClaw/n8n/CLI quickstarts | | `foundrygate-onboarding-validate` | Exits non-zero when onboarding blockers exist and prints warnings for common multi-provider and multi-client misconfigurations | | `foundrygate-install` | Installs the unit file, creates `/var/lib/foundrygate`, creates helper symlinks, reloads `systemd`, and starts the service | | `foundrygate-start` | Runs `systemctl start foundrygate.service` | diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index a07ada6..b73f3fb 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -28,6 +28,13 @@ $EDITOR .env `foundrygate-onboarding-report` now includes concrete OpenClaw, n8n, and CLI quickstart hints plus a staged provider-rollout view. Use it after every provider or client change to keep the deployment understandable for the next operator as well. +It also prints a client matrix: + +- which client profiles exist +- whether they come from presets or custom config +- how they match traffic +- which routing hints they actually apply + ### 1. Add one provider - define the provider in `config.yaml` @@ -90,6 +97,8 @@ Start with: Then tighten it only if the default is not good enough. +When the client set grows, use the client matrix from `foundrygate-onboarding-report` to catch profiles that only work through explicit overrides and still have no real match rule. + ### 3a. Start from one of the built-in quickstarts OpenClaw: diff --git a/foundrygate/onboarding.py b/foundrygate/onboarding.py index 75d9b1c..c9aafcc 100644 --- a/foundrygate/onboarding.py +++ b/foundrygate/onboarding.py @@ -113,6 +113,83 @@ def _build_provider_rollout( } +def _describe_client_match(match: dict[str, Any]) -> str: + """Return a compact text summary for one client-profile match rule.""" + parts: list[str] = [] + if match.get("header_present"): + parts.append("headers present: " + ", ".join(match["header_present"])) + if match.get("header_contains"): + header_parts = [ + f"{header}~{', '.join(values)}" + for header, values in sorted(match["header_contains"].items()) + ] + parts.append("header contains: " + "; ".join(header_parts)) + if match.get("any"): + any_parts = [] + for item in match["any"]: + summary = _describe_client_match(item) + if summary: + any_parts.append(summary) + if any_parts: + parts.append("any(" + " | ".join(any_parts) + ")") + if match.get("all"): + all_parts = [] + for item in match["all"]: + summary = _describe_client_match(item) + if summary: + all_parts.append(summary) + if all_parts: + parts.append("all(" + " & ".join(all_parts) + ")") + return "; ".join(parts) + + +def _summarize_profile_hints(profile: dict[str, Any]) -> list[str]: + """Return compact routing-intent text for one client profile.""" + hints: list[str] = [] + if profile.get("prefer_tiers"): + hints.append("prefer tiers: " + ", ".join(profile["prefer_tiers"])) + if profile.get("prefer_providers"): + hints.append("prefer providers: " + ", ".join(profile["prefer_providers"])) + if profile.get("allow_providers"): + hints.append("allow providers: " + ", ".join(profile["allow_providers"])) + if profile.get("deny_providers"): + hints.append("deny providers: " + ", ".join(profile["deny_providers"])) + if profile.get("require_capabilities"): + hints.append("require caps: " + ", ".join(profile["require_capabilities"])) + if profile.get("capability_values"): + value_parts = [] + for name, value in sorted(profile["capability_values"].items()): + rendered = value + if isinstance(value, list) and len(value) == 1: + rendered = value[0] + value_parts.append(f"{name}={rendered}") + hints.append("capability values: " + ", ".join(value_parts)) + return hints or ["no extra routing hints"] + + +def _build_client_matrix(client_profiles: dict[str, Any]) -> list[dict[str, Any]]: + """Return a report-friendly matrix of client profiles and match rules.""" + presets = set(client_profiles.get("presets", [])) + rules_by_profile = {rule["profile"]: rule["match"] for rule in client_profiles.get("rules", [])} + + matrix = [] + for name, profile in sorted(client_profiles.get("profiles", {}).items()): + match = rules_by_profile.get(name) + matrix.append( + { + "name": name, + "source": "preset" if name in presets else "custom", + "default": name == client_profiles.get("default", "generic"), + "matched_by": ( + _describe_client_match(match) if match else "default or explicit override" + ), + "routing_intent": _summarize_profile_hints(profile), + "has_rule": match is not None, + } + ) + return matrix + + def build_onboarding_report( *, config_path: str | Path | None = None, @@ -176,6 +253,7 @@ def build_onboarding_report( if update_check.get("enabled") and not auto_update.get("enabled"): suggestions.append("Keep auto_update disabled until the provider and client set is stable.") provider_rollout = _build_provider_rollout(providers, list(config.fallback_chain)) + client_matrix = _build_client_matrix(client_profiles) enabled_presets = set(client_profiles.get("presets", [])) profile_names = set(client_profiles.get("profiles", {}).keys()) @@ -246,6 +324,7 @@ def build_onboarding_report( "presets": list(client_profiles.get("presets", [])), "profile_count": len(client_profiles.get("profiles", {})), "rule_count": len(client_profiles.get("rules", [])), + "matrix": client_matrix, }, "routing": { "fallback_chain": list(config.fallback_chain), @@ -301,6 +380,14 @@ def build_onboarding_validation(report: dict[str, Any]) -> dict[str, Any]: warnings.append("Client profiles are disabled.") if clients["profiles_enabled"] and not clients["presets"]: warnings.append("No built-in client presets are enabled.") + if clients["profiles_enabled"] and clients["profile_count"] > 1 and clients["rule_count"] == 0: + warnings.append("Multiple client profiles are configured, but no client match rules exist.") + for row in clients.get("matrix", []): + if row["name"] != clients["default_profile"] and not row["has_rule"]: + warnings.append( + f"Client profile '{row['name']}' has no match rule and only applies" + " via explicit override." + ) if routing["request_hooks_enabled"] and routing["request_hook_count"] == 0: warnings.append("Request hooks are enabled but no hooks are configured.") @@ -359,6 +446,19 @@ def render_onboarding_report(report: dict[str, Any]) -> str: f"- presets: {preset_text}", f"- profiles: {client_block['profile_count']}", f"- rules: {client_block['rule_count']}", + ] + ) + + if client_block["matrix"]: + lines.extend(["", "Client matrix"]) + for row in client_block["matrix"]: + default_text = " [default]" if row["default"] else "" + lines.append(f"- {row['name']}{default_text}: {row['source']}") + lines.append(f" match: {row['matched_by']}") + lines.append(f" intent: {'; '.join(row['routing_intent'])}") + + lines.extend( + [ "", "Routing", f"- fallback chain: {fallback_text}", diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index f79f3dd..ff28ae5 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -258,6 +258,66 @@ def test_onboarding_report_marks_all_builtin_integrations_ready(tmp_path: Path): assert report["integrations"]["openclaw"]["recommended"] is True assert report["integrations"]["n8n"]["recommended"] is True assert report["integrations"]["cli"]["recommended"] is True + assert report["clients"]["matrix"][0]["name"] == "cli" + assert report["clients"]["matrix"][0]["has_rule"] is True + + +def test_onboarding_report_includes_client_matrix_and_unmatched_profile_warning( + tmp_path: Path, +): + env_file = tmp_path / ".env" + env_file.write_text("DEEPSEEK_API_KEY=sk-demo\n", encoding="utf-8") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +fallback_chain: + - deepseek-chat +providers: + deepseek-chat: + backend: openai-compat + base_url: "https://api.deepseek.com/v1" + api_key: "${DEEPSEEK_API_KEY}" + model: "deepseek-chat" + tier: default +client_profiles: + enabled: true + default: generic + presets: ["n8n"] + profiles: + generic: {} + local-only: + capability_values: + local: true + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: false + hooks: [] +update_check: + enabled: false +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + report = build_onboarding_report(config_path=config_file, env_file=env_file) + validation = build_onboarding_validation(report) + text = render_onboarding_report(report) + + local_only = next(row for row in report["clients"]["matrix"] if row["name"] == "local-only") + + assert local_only["matched_by"] == "default or explicit override" + assert "capability values: local=True" in local_only["routing_intent"] + assert ( + "Client profile 'local-only' has no match rule and only applies via explicit override." + in validation["warnings"] + ) + assert "Client matrix" in text + assert "match: default or explicit override" in text def test_onboarding_report_includes_provider_rollout_stages_and_gaps(tmp_path: Path):