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

### Added

- Added `foundrygate-onboarding-report` plus a testable onboarding report module for many-provider and many-client readiness checks

## v0.7.0 - 2026-03-12

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ If you want the fastest local bootstrap, use the generic helpers first:
./scripts/foundrygate-bootstrap
$EDITOR .env
./scripts/foundrygate-doctor
./scripts/foundrygate-onboarding-report
```

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 @@ -836,6 +837,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, and onboarding suggestions for many-provider and many-client setups |
| `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` |
| `foundrygate-stop` | Runs `systemctl stop foundrygate.service` |
Expand Down
8 changes: 8 additions & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Run the generic helpers before changing config:
./scripts/foundrygate-bootstrap
$EDITOR .env
./scripts/foundrygate-doctor
./scripts/foundrygate-onboarding-report
```

### 1. Add one provider
Expand All @@ -47,6 +48,13 @@ $EDITOR .env

Repeat the same path before introducing more routing complexity.

For many-provider rollouts, run the onboarding report after every provider change:

```bash
./scripts/foundrygate-onboarding-report
./scripts/foundrygate-onboarding-report --json
```

## Client onboarding sequence

### 1. Keep the client on the common API
Expand Down
208 changes: 208 additions & 0 deletions foundrygate/onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Onboarding reporting helpers for many-provider and many-client setups."""

from __future__ import annotations

from pathlib import Path
from typing import Any

from dotenv import dotenv_values, load_dotenv

from .config import load_config


def _env_path(env_file: str | Path | None = None) -> Path:
"""Return the effective env file path."""
if env_file is not None:
return Path(env_file)
return Path.cwd() / ".env"


def _provider_ready(provider: dict[str, Any]) -> tuple[bool, str]:
"""Return whether one provider looks ready for onboarding."""
contract = provider.get("contract", "generic")
backend = provider.get("backend", "openai-compat")
api_key = str(provider.get("api_key", "") or "").strip()
base_url = str(provider.get("base_url", "") or "").strip()

if contract == "local-worker":
if not base_url:
return False, "missing base_url"
return True, "local worker contract"

if contract == "image-provider" and not base_url:
return False, "missing base_url"

if backend in {"openai-compat", "google-genai", "anthropic-compat"} and not api_key:
return False, "missing api_key"

return True, "configured"


def build_onboarding_report(
*,
config_path: str | Path | None = None,
env_file: str | Path | None = None,
) -> dict[str, Any]:
"""Return a structured onboarding report for providers and clients."""
resolved_env = _env_path(env_file)
load_dotenv(resolved_env, override=True)
config = load_config(config_path)
env_values = dotenv_values(resolved_env) if resolved_env.exists() else {}

providers = []
ready = 0
local_workers = 0
image_capable = 0
missing_api_keys = []

for name, provider in sorted(config.providers.items()):
is_ready, readiness_reason = _provider_ready(provider)
if is_ready:
ready += 1
if provider.get("contract") == "local-worker":
local_workers += 1
capabilities = provider.get("capabilities") or {}
if capabilities.get("image_generation") or capabilities.get("image_editing"):
image_capable += 1
if readiness_reason == "missing api_key":
missing_api_keys.append(name)
providers.append(
{
"name": name,
"backend": provider.get("backend", "openai-compat"),
"contract": provider.get("contract", "generic"),
"model": provider.get("model", ""),
"tier": provider.get("tier", ""),
"ready": is_ready,
"readiness_reason": readiness_reason,
"capabilities": capabilities,
}
)

client_profiles = config.client_profiles
routing_policies = config.routing_policies
request_hooks = config.request_hooks
update_check = config.update_check
auto_update = config.auto_update

suggestions = []
if not providers:
suggestions.append("Add one provider before onboarding clients.")
if providers and ready == 0:
suggestions.append(
"Configure at least one ready provider with a real key or local worker URL."
)
if not client_profiles.get("enabled"):
suggestions.append("Enable client_profiles when multiple clients share one gateway.")
if not client_profiles.get("presets"):
suggestions.append("Start with client_profiles.presets for openclaw, n8n, or cli.")
if len(config.fallback_chain) == 0:
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.")

return {
"config_path": str(Path(config_path) if config_path else Path.cwd() / "config.yaml"),
"env_file": str(resolved_env),
"env": {
"exists": resolved_env.exists(),
"provider_keys_present": sorted(key for key, value in env_values.items() if value),
},
"providers": {
"total": len(providers),
"ready": ready,
"not_ready": len(providers) - ready,
"local_workers": local_workers,
"image_capable": image_capable,
"missing_api_keys": missing_api_keys,
"items": providers,
},
"clients": {
"profiles_enabled": bool(client_profiles.get("enabled")),
"default_profile": client_profiles.get("default", "generic"),
"presets": list(client_profiles.get("presets", [])),
"profile_count": len(client_profiles.get("profiles", {})),
"rule_count": len(client_profiles.get("rules", [])),
},
"routing": {
"fallback_chain": list(config.fallback_chain),
"policy_layer_enabled": bool(routing_policies.get("enabled")),
"policy_rule_count": len(routing_policies.get("rules", [])),
"request_hooks_enabled": bool(request_hooks.get("enabled")),
"request_hook_count": len(request_hooks.get("hooks", [])),
},
"operations": {
"update_checks_enabled": bool(update_check.get("enabled")),
"auto_update_enabled": bool(auto_update.get("enabled")),
"rollout_ring": auto_update.get("rollout_ring", "early"),
},
"suggestions": suggestions,
}


def render_onboarding_report(report: dict[str, Any]) -> str:
"""Render the onboarding report as plain text."""
provider_block = report["providers"]
client_block = report["clients"]
routing_block = report["routing"]
ops_block = report["operations"]
preset_text = ", ".join(client_block["presets"]) if client_block["presets"] else "none"
fallback_text = (
", ".join(routing_block["fallback_chain"]) if routing_block["fallback_chain"] else "none"
)

lines = [
"FoundryGate onboarding report",
"",
f"Config: {report['config_path']}",
f"Env : {report['env_file']}",
"",
"Providers",
f"- total: {provider_block['total']}",
f"- ready: {provider_block['ready']}",
f"- not ready: {provider_block['not_ready']}",
f"- local workers: {provider_block['local_workers']}",
f"- image-capable: {provider_block['image_capable']}",
]
if provider_block["missing_api_keys"]:
lines.append(f"- missing api_key: {', '.join(provider_block['missing_api_keys'])}")

if provider_block["items"]:
lines.append("")
lines.append("Provider inventory")
for item in provider_block["items"]:
readiness = "ready" if item["ready"] else f"not ready ({item['readiness_reason']})"
lines.append(
f"- {item['name']}: {item['contract']} / {item['backend']} / "
f"{item['tier'] or 'default'} / {readiness}"
)

lines.extend(
[
"",
"Clients",
f"- profiles enabled: {client_block['profiles_enabled']}",
f"- default profile: {client_block['default_profile']}",
f"- presets: {preset_text}",
f"- profiles: {client_block['profile_count']}",
f"- rules: {client_block['rule_count']}",
"",
"Routing",
f"- fallback chain: {fallback_text}",
f"- policy layer: {routing_block['policy_layer_enabled']} "
f"({routing_block['policy_rule_count']} rules)",
f"- request hooks: {routing_block['request_hooks_enabled']} "
f"({routing_block['request_hook_count']} hooks)",
"",
"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 report["suggestions"]:
lines.extend(["", "Suggestions"])
lines.extend(f"- {item}" for item in report["suggestions"])

return "\n".join(lines) + "\n"
1 change: 1 addition & 0 deletions scripts/foundrygate-install
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
helpers=(
foundrygate-bootstrap
foundrygate-doctor
foundrygate-onboarding-report
foundrygate-update-check
foundrygate-auto-update
foundrygate-install
Expand Down
28 changes: 28 additions & 0 deletions scripts/foundrygate-onboarding-report
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
config_file="${FOUNDRYGATE_CONFIG_FILE:-$repo_root/config.yaml}"
env_file="${FOUNDRYGATE_ENV_FILE:-$repo_root/.env}"
mode="${1:-text}"

export FOUNDRYGATE_ONBOARDING_CONFIG="$config_file"
export FOUNDRYGATE_ONBOARDING_ENV="$env_file"
export FOUNDRYGATE_ONBOARDING_MODE="$mode"

python3 - <<'PY'
import json
import os

from foundrygate.onboarding import build_onboarding_report, render_onboarding_report

report = build_onboarding_report(
config_path=os.environ["FOUNDRYGATE_ONBOARDING_CONFIG"],
env_file=os.environ["FOUNDRYGATE_ONBOARDING_ENV"],
)

if os.environ["FOUNDRYGATE_ONBOARDING_MODE"] == "--json":
print(json.dumps(report, indent=2, sort_keys=True))
else:
print(render_onboarding_report(report), end="")
PY
1 change: 1 addition & 0 deletions scripts/foundrygate-uninstall
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
set -euo pipefail
helpers=(
foundrygate-install
foundrygate-onboarding-report
foundrygate-start
foundrygate-stop
foundrygate-restart
Expand Down
Loading
Loading