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 @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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` |
Expand Down
9 changes: 9 additions & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand Down
100 changes: 100 additions & 0 deletions foundrygate/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.")

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