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 @@ -11,6 +11,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
- Added `foundrygate-onboarding-report` plus a testable onboarding report module for many-provider and many-client readiness checks
- 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

## 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 so you can move from a generic health check to a client-specific integration path without leaving the terminal.
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.

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, 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, 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
10 changes: 9 additions & 1 deletion docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ $EDITOR .env
./scripts/foundrygate-onboarding-report
```

`foundrygate-onboarding-report` now includes concrete OpenClaw, n8n, and CLI quickstart hints. Use it after every provider or client change to keep the deployment understandable for the next operator as well.
`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.

### 1. Add one provider

Expand Down Expand Up @@ -58,6 +58,14 @@ For many-provider rollouts, run the onboarding report after every provider chang
./scripts/foundrygate-onboarding-validate
```

The rollout section is intentionally staged:

1. stage 1 primary: ready local/default chat providers
2. stage 2 secondary: additional non-image providers
3. stage 3 modality: image-capable providers

This keeps provider growth incremental instead of introducing chat, fallback, and modality changes all at once.

## Client onboarding sequence

### 1. Keep the client on the common API
Expand Down
85 changes: 85 additions & 0 deletions foundrygate/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,71 @@ def _provider_ready(provider: dict[str, Any]) -> tuple[bool, str]:
return True, "configured"


def _build_provider_rollout(
providers: list[dict[str, Any]],
fallback_chain: list[str],
) -> dict[str, Any]:
"""Return a staged rollout view for many-provider setups."""
primary_stage: list[str] = []
secondary_stage: list[str] = []
modality_stage: list[str] = []

provider_index = {provider["name"]: provider for provider in providers}
fallback_status: list[dict[str, Any]] = []

for provider in providers:
if not provider["ready"]:
continue

capabilities = provider.get("capabilities") or {}
is_image_capable = capabilities.get("image_generation") or capabilities.get("image_editing")
if provider.get("contract") == "image-provider" or is_image_capable:
modality_stage.append(provider["name"])
continue

if provider.get("tier") in {"local", "default"}:
primary_stage.append(provider["name"])
else:
secondary_stage.append(provider["name"])

ready_fallback_targets = 0
for name in fallback_chain:
provider = provider_index.get(name)
is_ready = bool(provider and provider["ready"])
if is_ready:
ready_fallback_targets += 1
fallback_status.append(
{
"name": name,
"configured": provider is not None,
"ready": is_ready,
}
)

gaps: list[str] = []
if providers and not primary_stage:
gaps.append("No ready primary provider is available for the first rollout stage.")
if len(providers) > 1 and fallback_chain and ready_fallback_targets == 0:
gaps.append("Fallback chain is configured, but none of its targets are currently ready.")
if (
any(
(provider.get("capabilities") or {}).get("image_generation")
or (provider.get("capabilities") or {}).get("image_editing")
for provider in providers
)
and not modality_stage
):
gaps.append("Image-capable providers are configured, but none are ready yet.")

return {
"stage_1_primary": primary_stage,
"stage_2_secondary": secondary_stage,
"stage_3_modality": modality_stage,
"fallback_targets": fallback_status,
"gaps": gaps,
}


def build_onboarding_report(
*,
config_path: str | Path | None = None,
Expand Down Expand Up @@ -110,6 +175,7 @@ def build_onboarding_report(
suggestions.append("Set a fallback_chain before onboarding multiple clients.")
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))

enabled_presets = set(client_profiles.get("presets", []))
profile_names = set(client_profiles.get("profiles", {}).keys())
Expand Down Expand Up @@ -188,6 +254,7 @@ def build_onboarding_report(
"request_hooks_enabled": bool(request_hooks.get("enabled")),
"request_hook_count": len(request_hooks.get("hooks", [])),
},
"provider_rollout": provider_rollout,
"operations": {
"update_checks_enabled": bool(update_check.get("enabled")),
"auto_update_enabled": bool(auto_update.get("enabled")),
Expand All @@ -203,6 +270,7 @@ def build_onboarding_validation(report: dict[str, Any]) -> dict[str, Any]:
providers = report["providers"]
clients = report["clients"]
routing = report["routing"]
provider_rollout = report["provider_rollout"]
env = report["env"]

blockers: list[str] = []
Expand All @@ -217,12 +285,17 @@ def build_onboarding_validation(report: dict[str, Any]) -> dict[str, Any]:

if providers["total"] > 1 and not routing["fallback_chain"]:
blockers.append("Fallback chain is empty for a multi-provider setup.")
if providers["total"] > 1 and not provider_rollout["stage_1_primary"]:
blockers.append(
"No ready primary provider is available for a staged multi-provider rollout."
)

if providers["not_ready"] > 0:
warnings.append(
f"{providers['not_ready']} provider(s) are not ready: "
+ ", ".join(item["name"] for item in providers["items"] if not item["ready"])
)
warnings.extend(provider_rollout["gaps"])

if not clients["profiles_enabled"]:
warnings.append("Client profiles are disabled.")
Expand All @@ -243,6 +316,7 @@ def render_onboarding_report(report: dict[str, Any]) -> str:
provider_block = report["providers"]
client_block = report["clients"]
routing_block = report["routing"]
rollout_block = report["provider_rollout"]
ops_block = report["operations"]
integration_block = report["integrations"]
preset_text = ", ".join(client_block["presets"]) if client_block["presets"] else "none"
Expand Down Expand Up @@ -293,13 +367,24 @@ def render_onboarding_report(report: dict[str, Any]) -> str:
f"- request hooks: {routing_block['request_hooks_enabled']} "
f"({routing_block['request_hook_count']} hooks)",
"",
"Provider rollout",
"- stage 1 primary: " + (", ".join(rollout_block["stage_1_primary"]) or "none"),
"- stage 2 secondary: " + (", ".join(rollout_block["stage_2_secondary"]) or "none"),
"- stage 3 modality: " + (", ".join(rollout_block["stage_3_modality"]) or "none"),
"",
"Operations",
f"- update checks: {ops_block['update_checks_enabled']}",
f"- auto update: {ops_block['auto_update_enabled']}",
f"- rollout ring: {ops_block['rollout_ring']}",
]
)

if rollout_block["fallback_targets"]:
lines.append("- fallback targets:")
for item in rollout_block["fallback_targets"]:
readiness = "ready" if item["ready"] else "not ready"
lines.append(f" - {item['name']}: {readiness}")

lines.extend(["", "Integration quickstarts"])
for client_name, data in integration_block.items():
readiness = "ready" if data["recommended"] else "needs preset or custom profile"
Expand Down
74 changes: 74 additions & 0 deletions tests/test_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ def test_onboarding_report_marks_local_worker_ready(tmp_path: Path):

assert report["providers"]["ready"] == 1
assert report["providers"]["local_workers"] == 1
assert report["provider_rollout"]["stage_1_primary"] == ["local-worker"]
assert "local-worker: local-worker / openai-compat / local / ready" in text
assert "- stage 1 primary: local-worker" in text
assert "Integration quickstarts" in text
assert "header: X-FoundryGate-Client: codex" in text

Expand Down Expand Up @@ -161,6 +163,10 @@ def test_onboarding_validation_blocks_missing_env_and_unready_providers(
assert "Environment file is missing." in validation["blockers"]
assert "No configured provider is ready." in validation["blockers"]
assert "Fallback chain is empty for a multi-provider setup." in validation["blockers"]
assert (
"No ready primary provider is available for a staged multi-provider rollout."
in validation["blockers"]
)
assert "Client profiles are disabled." in validation["warnings"]
assert "Request hooks are enabled but no hooks are configured." in validation["warnings"]
assert "Status: blocked" in text
Expand Down Expand Up @@ -252,3 +258,71 @@ 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


def test_onboarding_report_includes_provider_rollout_stages_and_gaps(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text("PRIMARY_KEY=sk-primary\n", encoding="utf-8")

config_file = tmp_path / "config.yaml"
config_file.write_text(
"""
fallback_chain:
- image-worker
providers:
primary-chat:
backend: openai-compat
base_url: "https://api.example.com/v1"
api_key: "${PRIMARY_KEY}"
model: "primary-chat"
tier: default
image-worker:
contract: image-provider
backend: openai-compat
base_url: "http://127.0.0.1:9000/v1"
api_key: "${IMAGE_KEY}"
model: "image-model"
tier: specialty
capabilities:
image_generation: true
client_profiles:
enabled: true
default: generic
presets: ["openclaw", "n8n"]
profiles:
generic: {}
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)

assert report["provider_rollout"]["stage_1_primary"] == ["primary-chat"]
assert report["provider_rollout"]["stage_2_secondary"] == []
assert report["provider_rollout"]["stage_3_modality"] == []
assert report["provider_rollout"]["fallback_targets"] == [
{"name": "image-worker", "configured": True, "ready": False}
]
assert (
"Image-capable providers are configured, but none are ready yet."
in report["provider_rollout"]["gaps"]
)
assert (
"Fallback chain is configured, but none of its targets are currently ready."
in validation["warnings"]
)
assert "- stage 1 primary: primary-chat" in text
assert "- fallback targets:" in text
Loading