@crvouga/dev-fleet 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
- package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
- package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
- package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
- package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
- package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/agents/antigravity-proxy.ts +3 -0
- package/agents/audit.md +89 -0
- package/agents/claude-proxy +5 -0
- package/agents/claude-proxy.ts +3 -0
- package/agents/codex-proxy.ts +3 -0
- package/agents/grok-proxy.ts +3 -0
- package/agents/managed-proxy.ts +55 -0
- package/agents/opencode-proxy.ts +3 -0
- package/agents/registry.json +209 -0
- package/agents/registry.schema.json +149 -0
- package/agents/setup.md +128 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +17 -0
- package/ansible/monitoring/README.md +154 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +134 -0
- package/ansible/monitoring/beszel-agent.service +42 -0
- package/ansible/monitoring/compose.yml +74 -0
- package/ansible/monitoring/hub-darwin.yml +102 -0
- package/ansible/monitoring/lock.json +11 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +43 -0
- package/ansible/ubuntu/recovery.yml +15 -0
- package/ansible/ubuntu/t3/policy.json +7 -0
- package/ansible/ubuntu/t3-desktop.yml +220 -0
- package/ansible/ubuntu/t3-health.sh +33 -0
- package/ansible/ubuntu/t3-policy.yml +76 -0
- package/ansible/ubuntu/t3-route.yml +28 -0
- package/ansible/ubuntu/tailscale.yml +75 -0
- package/ansible/ubuntu/tasks/credentials.yml +47 -0
- package/ansible/ubuntu/tasks/docker.yml +31 -0
- package/ansible/ubuntu/tasks/packages.yml +56 -0
- package/ansible/ubuntu/tasks/power.yml +51 -0
- package/ansible/ubuntu/tasks/recovery.yml +39 -0
- package/ansible/ubuntu/tasks/resources.yml +252 -0
- package/ansible/ubuntu/tasks/services.yml +31 -0
- package/ansible/ubuntu/tasks/ssh.yml +71 -0
- package/ansible/ubuntu/tasks/user.yml +120 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +73 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +189 -0
- package/bootstrap/fleet-manager-run.sh +76 -0
- package/bootstrap/fleet-remote-bootstrap.sh +185 -0
- package/bootstrap/global-config.ts +113 -0
- package/bootstrap/install-launcher.sh +7 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +350 -0
- package/bootstrap/setup-status.ts +51 -0
- package/bootstrap/setup.ts +88 -0
- package/bootstrap/shell-path.ts +105 -0
- package/bootstrap/verify-t3-archive.py +49 -0
- package/bootstrap/worker-auth.ts +65 -0
- package/bootstrap/worker-github.ts +47 -0
- package/bootstrap/worker-tools.ts +24 -0
- package/cliproxy/AGENTS.md +11 -0
- package/cliproxy/README.md +269 -0
- package/cliproxy/clients/bootstrap.py +143 -0
- package/cliproxy/clients/launcher.py +57 -0
- package/cliproxy/compose.yaml +90 -0
- package/cliproxy/config.template.yaml +70 -0
- package/cliproxy/docs/cloud-adoption.md +16 -0
- package/cliproxy/docs/deployment.png +0 -0
- package/cliproxy/docs/deployment.svg +109 -0
- package/cliproxy/docs/health-workflow.example.yml +185 -0
- package/cliproxy/docs/migration.md +131 -0
- package/cliproxy/docs/model-health-report.schema.json +364 -0
- package/cliproxy/docs/model-health.md +393 -0
- package/cliproxy/image.lock.json +5 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
- package/cliproxy/models.json +1618 -0
- package/cliproxy/requirements.lock +78 -0
- package/cliproxy/scripts/accounts.py +38 -0
- package/cliproxy/scripts/audit.py +123 -0
- package/cliproxy/scripts/backup +234 -0
- package/cliproxy/scripts/check.py +34 -0
- package/cliproxy/scripts/checkpoint.py +226 -0
- package/cliproxy/scripts/cli.py +64 -0
- package/cliproxy/scripts/cliproxy_host.py +257 -0
- package/cliproxy/scripts/cloud_state.py +150 -0
- package/cliproxy/scripts/container-health +6 -0
- package/cliproxy/scripts/docker +24 -0
- package/cliproxy/scripts/external-health.py +79 -0
- package/cliproxy/scripts/fleet_config.py +43 -0
- package/cliproxy/scripts/fleet_sync.py +274 -0
- package/cliproxy/scripts/initialize +84 -0
- package/cliproxy/scripts/install-dependencies +48 -0
- package/cliproxy/scripts/install.py +406 -0
- package/cliproxy/scripts/model_health.py +16 -0
- package/cliproxy/scripts/modelhealth/__init__.py +5 -0
- package/cliproxy/scripts/modelhealth/checks.py +429 -0
- package/cliproxy/scripts/modelhealth/cli.py +117 -0
- package/cliproxy/scripts/modelhealth/config.py +186 -0
- package/cliproxy/scripts/modelhealth/emulator.py +536 -0
- package/cliproxy/scripts/modelhealth/expect.py +88 -0
- package/cliproxy/scripts/modelhealth/findings.py +183 -0
- package/cliproxy/scripts/modelhealth/protocol.py +330 -0
- package/cliproxy/scripts/modelhealth/redact.py +28 -0
- package/cliproxy/scripts/modelhealth/report.py +150 -0
- package/cliproxy/scripts/modelhealth/runner.py +390 -0
- package/cliproxy/scripts/modelhealth/scenario.py +131 -0
- package/cliproxy/scripts/modelhealth/schema.py +63 -0
- package/cliproxy/scripts/modelhealth/simulate.py +111 -0
- package/cliproxy/scripts/modelhealth/transport.py +213 -0
- package/cliproxy/scripts/monitor +68 -0
- package/cliproxy/scripts/ops +527 -0
- package/cliproxy/scripts/public-endpoint +127 -0
- package/cliproxy/scripts/recover_node.py +177 -0
- package/cliproxy/scripts/restore-verify +60 -0
- package/cliproxy/scripts/retention.py +144 -0
- package/cliproxy/scripts/service-start +7 -0
- package/cliproxy/scripts/validate-config +56 -0
- package/cliproxy/scripts/vault-access.py +57 -0
- package/cliproxy/scripts/vault-backup-login +55 -0
- package/cliproxy/scripts/vault-recovery +56 -0
- package/cliproxy/scripts/vault-renew +30 -0
- package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
- package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
- package/cliproxy/systemd/cliproxy-backup.service +13 -0
- package/cliproxy/systemd/cliproxy-backup.timer +8 -0
- package/cliproxy/systemd/cliproxy-fleet.service +17 -0
- package/cliproxy/systemd/cliproxy-gc.service +13 -0
- package/cliproxy/systemd/cliproxy-gc.timer +8 -0
- package/cliproxy/systemd/cliproxy-monitor.service +8 -0
- package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
- package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
- package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
- package/cliproxy/vendor/LICENSE +22 -0
- package/cliproxy/vendor/config.upstream.yaml +1349 -0
- package/examples/fleet.json +95 -0
- package/examples/roster.json +13 -0
- package/mise.lock +1492 -0
- package/mise.toml +99 -0
- package/package.json +65 -4
- package/schema/fleet.schema.json +364 -0
- package/schema/roster.schema.json +77 -0
- package/src/agents/account-relay.ts +111 -0
- package/src/agents/accounts.ts +85 -0
- package/src/agents/audit.ts +77 -0
- package/src/agents/cli.ts +13 -0
- package/src/agents/command.ts +204 -0
- package/src/agents/converge.ts +123 -0
- package/src/agents/doctor.ts +107 -0
- package/src/agents/errors.ts +2 -0
- package/src/agents/files.ts +73 -0
- package/src/agents/fleet-verify.ts +156 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +226 -0
- package/src/agents/provider-errors.ts +50 -0
- package/src/agents/registry.ts +55 -0
- package/src/agents/render.ts +110 -0
- package/src/agents/round-trip.ts +192 -0
- package/src/agents/secrets.ts +13 -0
- package/src/agents/toolchain.ts +34 -0
- package/src/agents/verify-cache.ts +103 -0
- package/src/agents/verify.ts +127 -0
- package/src/auth-checks.ts +163 -0
- package/src/cli/agent-guide.ts +95 -0
- package/src/cli/app.ts +115 -0
- package/src/cli/cli.ts +21 -0
- package/src/cli/command-index.ts +50 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-sync-checkout.ts +91 -0
- package/src/cli/fleet-sync-enrollment.ts +144 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +193 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +314 -0
- package/src/cli/fleet-sync-privilege.ts +27 -0
- package/src/cli/fleet-sync-progress.ts +109 -0
- package/src/cli/fleet-sync-remote.ts +236 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +309 -0
- package/src/cli/fleet.ts +163 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +114 -0
- package/src/cli/node-t3.ts +31 -0
- package/src/cli/options.ts +141 -0
- package/src/cli/probe-source.ts +7 -0
- package/src/cli/program.ts +169 -0
- package/src/cli/register-fleet.ts +246 -0
- package/src/cli/registry.ts +158 -0
- package/src/cli/repair-apply.ts +67 -0
- package/src/cli/repair-guard.ts +11 -0
- package/src/cli/repair-inspect.ts +150 -0
- package/src/cli/repair-script.ts +76 -0
- package/src/cli/repair.ts +209 -0
- package/src/cli/roster.ts +89 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +64 -0
- package/src/cli/source-operations.ts +78 -0
- package/src/cli/t3/command.ts +90 -0
- package/src/cli/t3/register.ts +55 -0
- package/src/cli/tailnet.ts +127 -0
- package/src/cli/ui-actions.ts +133 -0
- package/src/cli/ui-controller.ts +113 -0
- package/src/cli/ui-data.ts +106 -0
- package/src/cli/ui-execute.ts +163 -0
- package/src/cli/ui-repairs.ts +34 -0
- package/src/cli/ui-reports.ts +211 -0
- package/src/cli/ui-targets.ts +100 -0
- package/src/cli/ui-terminal.ts +109 -0
- package/src/cli/ui-types.ts +49 -0
- package/src/cli/ui-wizard.ts +101 -0
- package/src/cli/ui.ts +9 -0
- package/src/cli/worker-health-output.ts +37 -0
- package/src/config.ts +186 -0
- package/src/configure.ts +255 -0
- package/src/definition.ts +260 -0
- package/src/diagnostics.ts +92 -0
- package/src/doctor-check.ts +10 -0
- package/src/errors.ts +1 -0
- package/src/fingerprint.ts +120 -0
- package/src/fleet-config.ts +88 -0
- package/src/index.ts +6 -0
- package/src/input-hashes.ts +73 -0
- package/src/machine/local-source.ts +52 -0
- package/src/machine/lock-error.ts +2 -0
- package/src/machine/platform.ts +44 -0
- package/src/machine/source.ts +68 -0
- package/src/machine/state.ts +185 -0
- package/src/machine-health.ts +95 -0
- package/src/managed-source.ts +47 -0
- package/src/manager-failure.ts +48 -0
- package/src/manager-sync.ts +400 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/apply.ts +171 -0
- package/src/monitoring/beszel.ts +191 -0
- package/src/monitoring/enroll.ts +110 -0
- package/src/monitoring/hub-access.ts +113 -0
- package/src/monitoring/hub-viewer.ts +142 -0
- package/src/monitoring/hub.ts +93 -0
- package/src/monitoring/manager-agent.ts +73 -0
- package/src/monitoring/manager-hub.ts +232 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +372 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +62 -0
- package/src/platform/darwin.ts +203 -0
- package/src/platform/detect.ts +11 -0
- package/src/platform/fallback.ts +110 -0
- package/src/platform/index.ts +38 -0
- package/src/platform/launchd.ts +98 -0
- package/src/platform/linux.ts +139 -0
- package/src/platform/ps.ts +45 -0
- package/src/platform/resource-verify.ts +128 -0
- package/src/platform/systemd-user.ts +95 -0
- package/src/platform/t3-policy.ts +137 -0
- package/src/platform/t3-route.ts +68 -0
- package/src/platform/t3-service.ts +75 -0
- package/src/platform/types.ts +123 -0
- package/src/platform/ubuntu-audit.ts +63 -0
- package/src/platform/ubuntu-verify.ts +105 -0
- package/src/platform/windows.ts +148 -0
- package/src/power-recovery.ts +55 -0
- package/src/probe.ts +51 -0
- package/src/protocol.ts +119 -0
- package/src/proxy-converge.ts +85 -0
- package/src/remote-bootstrap.ts +212 -0
- package/src/remote-entry.ts +89 -0
- package/src/remote-lock.ts +12 -0
- package/src/repair-catalog.ts +206 -0
- package/src/repair-plan.ts +78 -0
- package/src/repair-runner.ts +84 -0
- package/src/report.ts +71 -0
- package/src/runner.ts +104 -0
- package/src/secrets.ts +169 -0
- package/src/service-auth.ts +115 -0
- package/src/ssh.ts +135 -0
- package/src/sync.ts +104 -0
- package/src/t3/accounts.ts +81 -0
- package/src/t3/desired.ts +165 -0
- package/src/t3/files.ts +81 -0
- package/src/t3/names-check.ts +53 -0
- package/src/t3/names.ts +155 -0
- package/src/t3/plan.ts +82 -0
- package/src/t3/providers.ts +74 -0
- package/src/t3/reconcile.ts +123 -0
- package/src/t3-endpoint.ts +48 -0
- package/src/t3-linked-check.ts +101 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/test-fixtures.ts +36 -0
- package/src/toolchain-checks.ts +83 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +95 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +115 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +176 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- package/src/workstation-checks.ts +29 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Human-readable rendering of the structured health report (Markdown, readable in a terminal)."""
|
|
2
|
+
|
|
3
|
+
ICON = {"healthy": "HEALTHY", "degraded": "DEGRADED", "unhealthy": "UNHEALTHY", "unknown": "UNKNOWN"}
|
|
4
|
+
RESULT_ORDER = ["fail", "observed_unsupported", "untested", "unsupported", "pass"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _ms(value):
|
|
8
|
+
return "-" if value is None else "%dms" % value
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pct(value):
|
|
12
|
+
return "-" if value is None else "%d%%" % round(value * 100)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _row(cells):
|
|
16
|
+
return "| " + " | ".join(str(c) for c in cells) + " |"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render(report, config):
|
|
20
|
+
out = []
|
|
21
|
+
summary = report["summary"]
|
|
22
|
+
out.append("# CLIProxyAPI model health: %s" % ICON[summary["status"]])
|
|
23
|
+
out.append("")
|
|
24
|
+
target = report["target"]
|
|
25
|
+
out.append("Profile `%s`, suite `%s`, target `%s` (%s), seed %s, %s." % (
|
|
26
|
+
report["profile"], report["suite"], target["kind"], target["base_url"], report["seed"], report["generated_at"]))
|
|
27
|
+
if "budget" in report:
|
|
28
|
+
b = report["budget"]
|
|
29
|
+
out.append("Spend: %d of %d requests, %d of %d output tokens; concurrency %d; inference retries %d (never retried)." % (
|
|
30
|
+
b["requests_used"], b["requests_max"], b["output_tokens_used"], b["output_tokens_max"], b["concurrency"],
|
|
31
|
+
b["inference_retries"]))
|
|
32
|
+
out.append("")
|
|
33
|
+
out += _findings(report)
|
|
34
|
+
out.append("## Levels")
|
|
35
|
+
for name, level in report["levels"].items():
|
|
36
|
+
detail = "; ".join(level["details"]) or ("category " + level["category"] if level["category"] else "")
|
|
37
|
+
extra = ""
|
|
38
|
+
if name == "readiness" and level.get("served_models") is not None:
|
|
39
|
+
extra = " %d models served" % level["served_models"]
|
|
40
|
+
if level.get("discovered_unconfigured"):
|
|
41
|
+
extra += "; discovered but not configured: " + ", ".join(level["discovered_unconfigured"])
|
|
42
|
+
out.append("- **%s**: %s%s%s" % (name, ICON[level["status"]], extra, (" (" + detail + ")") if detail else ""))
|
|
43
|
+
out.append("")
|
|
44
|
+
if report["profile"] == "liveness":
|
|
45
|
+
out.append("Models are not evaluated at the liveness level: it needs no credentials and proves only reachability.")
|
|
46
|
+
out.append("")
|
|
47
|
+
elif report["models"]:
|
|
48
|
+
out.append("## Models")
|
|
49
|
+
out.append(_row(["Model", "Status", "Exercised", "Pass", "Fail", "Untested", "Unsupported", "TTFT p50", "Latency p95",
|
|
50
|
+
"Streams ok", "Tools ok", "Last healthy"]))
|
|
51
|
+
out.append(_row(["---"] * 12))
|
|
52
|
+
for m in report["models"]:
|
|
53
|
+
by = m["coverage"]["by_result"]
|
|
54
|
+
metrics = m["metrics"]
|
|
55
|
+
out.append(_row([m["id"], ICON[m["status"]], "%d/%d" % (m["coverage"]["exercised"], m["coverage"]["capabilities"]),
|
|
56
|
+
by.get("pass", 0), by.get("fail", 0), by.get("untested", 0),
|
|
57
|
+
by.get("unsupported", 0) + by.get("observed_unsupported", 0),
|
|
58
|
+
_ms(metrics["ttft_ms"]["p50"]), _ms(metrics["latency_ms"]["p95"]),
|
|
59
|
+
_pct(metrics["streaming_completion_rate"]), _pct(metrics["tool_call_success_rate"]),
|
|
60
|
+
(m.get("last_success") or {}).get("healthy_at") or "never recorded"]))
|
|
61
|
+
out.append("")
|
|
62
|
+
out.append("`untested` and `unsupported` capabilities are never counted as healthy. A model that is `healthy` is healthy only for the exercised capabilities.")
|
|
63
|
+
out.append("")
|
|
64
|
+
for m in report["models"]:
|
|
65
|
+
out += _model(m)
|
|
66
|
+
if report["simulations"]:
|
|
67
|
+
out.append("## Simulated scenarios (emulator, not live evidence)")
|
|
68
|
+
out.append(_row(["Scenario", "Capability", "Result", "Detail"]))
|
|
69
|
+
out.append(_row(["---"] * 4))
|
|
70
|
+
for sim in report["simulations"]:
|
|
71
|
+
out.append(_row([sim["id"], sim["capability"], sim["status"].upper(), "; ".join(sim["details"]) or sim["title"]]))
|
|
72
|
+
out.append("")
|
|
73
|
+
routing = report["routing"]["declared"]
|
|
74
|
+
out.append("Declared routing policy: `%s`, session affinity %s (live strategy is not observable through the API; the host monitor compares the running config with the template)." % (
|
|
75
|
+
routing["strategy"], "on" if routing["session_affinity"] else "off"))
|
|
76
|
+
out.append("")
|
|
77
|
+
out.append("Account failover, cooldown recovery and all-accounts-unavailable behavior cannot be induced against the hosted proxy without exhausting real subscription quota, so live routing stays `untested`.")
|
|
78
|
+
out.append("")
|
|
79
|
+
if report["profile"] != "liveness":
|
|
80
|
+
out += _cursor(report, config)
|
|
81
|
+
return "\n".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _findings(report):
|
|
85
|
+
findings = report["findings"]
|
|
86
|
+
if not findings:
|
|
87
|
+
return ["## Findings", "", "None. `untested` capabilities below are not claims of health.", ""]
|
|
88
|
+
out = ["## Findings (%d: %d need an operator, %d an agent can attempt)" % (
|
|
89
|
+
len(findings), report["summary"]["findings"]["operator"], report["summary"]["findings"]["agent"]), ""]
|
|
90
|
+
out.append(_row(["Finding", "Who fixes", "Category", "What", "Reproduce"]))
|
|
91
|
+
out.append(_row(["---"] * 5))
|
|
92
|
+
for f in findings:
|
|
93
|
+
out.append(_row(["`%s`" % f["id"], "agent" if f["agent_fixable"] else "operator", f["category"], f["summary"],
|
|
94
|
+
"`%s`" % f["reproduce"]]))
|
|
95
|
+
out += ["", "Evidence traces and fix hints for each finding are in `model-health.json` (`findings[]`) and `agent-brief.md`.", ""]
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _model(m):
|
|
100
|
+
out = ["### %s: %s (%s)" % (m["id"], ICON[m["status"]], m["provider"])]
|
|
101
|
+
for reason in m["reasons"]:
|
|
102
|
+
out.append("- " + reason)
|
|
103
|
+
metrics = m["metrics"]
|
|
104
|
+
if metrics["requests"]:
|
|
105
|
+
out.append("- Requests %d, availability %s, check success %s, resolved model id: %s. Error categories: %s." % (
|
|
106
|
+
metrics["requests"], _pct(metrics["availability"]), _pct(metrics["success_rate"]),
|
|
107
|
+
", ".join(metrics["resolved_models"]) or "-",
|
|
108
|
+
", ".join("%s=%d" % kv for kv in sorted(metrics["error_categories"].items())) or "none"))
|
|
109
|
+
last = m.get("last_success") or {}
|
|
110
|
+
out.append("- Last successful validation: healthy %s; any passing check %s." % (
|
|
111
|
+
last.get("healthy_at") or "never recorded", last.get("any_check_at") or "never recorded"))
|
|
112
|
+
rows = m["capabilities"]
|
|
113
|
+
passed = sorted(c for c, r in rows.items() if r["result"] == "pass")
|
|
114
|
+
if passed:
|
|
115
|
+
out.append("- **Passed (%d):** %s" % (len(passed), ", ".join(passed)))
|
|
116
|
+
detailed = sorted(((c, r) for c, r in rows.items() if r["result"] in ("fail", "observed_unsupported")),
|
|
117
|
+
key=lambda item: (RESULT_ORDER.index(item[1]["result"]), item[0]))
|
|
118
|
+
grouped = {}
|
|
119
|
+
for capability, row in sorted(rows.items()):
|
|
120
|
+
if row["result"] in ("untested", "unsupported"):
|
|
121
|
+
grouped.setdefault((row["result"], row["reason"] or ""), []).append(capability)
|
|
122
|
+
for (result, reason), capabilities in sorted(grouped.items(), key=lambda item: (RESULT_ORDER.index(item[0][0]), item[0][1])):
|
|
123
|
+
out.append("- **%s (%d)**, %s: %s" % (result.upper(), len(capabilities), reason or "no reason recorded", ", ".join(capabilities)))
|
|
124
|
+
if detailed:
|
|
125
|
+
out.append("")
|
|
126
|
+
out.append(_row(["Capability", "Declared", "Result", "Why"]))
|
|
127
|
+
out.append(_row(["---"] * 4))
|
|
128
|
+
for capability, row in detailed:
|
|
129
|
+
out.append(_row([capability, row["declared"], row["result"].upper().replace("_", " "), row["reason"] or ""]))
|
|
130
|
+
out.append("")
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cursor(report, config):
|
|
135
|
+
cursor = report["cursor"]
|
|
136
|
+
out = ["## Cursor-style agent compatibility (protocol level)", "", cursor["note"], "",
|
|
137
|
+
"Verified with the actual Cursor application: **%s**." % ("yes" if cursor["application_verified"] else "no"), ""]
|
|
138
|
+
ids = [case.capability for case in config.cases if "cursor" in case.check["suites"]]
|
|
139
|
+
if report["models"]:
|
|
140
|
+
out.append(_row(["Model", "Pass", "Fail", "Untested", "Unsupported"]))
|
|
141
|
+
out.append(_row(["---"] * 5))
|
|
142
|
+
for m in report["models"]:
|
|
143
|
+
counts = {}
|
|
144
|
+
for capability in ids:
|
|
145
|
+
result = m["capabilities"][capability]["result"]
|
|
146
|
+
counts[result] = counts.get(result, 0) + 1
|
|
147
|
+
out.append(_row([m["id"], counts.get("pass", 0), counts.get("fail", 0), counts.get("untested", 0),
|
|
148
|
+
counts.get("unsupported", 0) + counts.get("observed_unsupported", 0)]))
|
|
149
|
+
out.append("")
|
|
150
|
+
return out
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Run liveness, readiness and end-to-end levels; derive independent per-model health."""
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from . import checks, findings, protocol
|
|
12
|
+
from .redact import Redactor
|
|
13
|
+
from .transport import Client, TransportError
|
|
14
|
+
|
|
15
|
+
SEVERITY_ORDER = {"critical": 0, "major": 1, "minor": 2}
|
|
16
|
+
SCHEMA = "cliproxy-model-health/1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse(timestamp):
|
|
20
|
+
return datetime.datetime.fromisoformat(timestamp)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def now_iso():
|
|
24
|
+
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def percentile(values, fraction):
|
|
28
|
+
if not values:
|
|
29
|
+
return None
|
|
30
|
+
ordered = sorted(values)
|
|
31
|
+
return ordered[min(len(ordered) - 1, int(round(fraction * (len(ordered) - 1))))]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _level(status, category=None, details=None, **extra):
|
|
35
|
+
return dict({"status": status, "category": category, "details": details or []}, **extra)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------- levels
|
|
39
|
+
|
|
40
|
+
def liveness(base_url, redact, timeout):
|
|
41
|
+
"""Unauthenticated and free: the API rejects anonymous callers and the dashboard serves."""
|
|
42
|
+
origin = base_url.rstrip("/")
|
|
43
|
+
origin = origin[: -len("/v1")] if origin.endswith("/v1") else origin
|
|
44
|
+
api, dashboard = Client(base_url, None, redact, timeout), Client(origin, None, redact, timeout)
|
|
45
|
+
result = _level("healthy", probes={})
|
|
46
|
+
try:
|
|
47
|
+
response = api.request("GET", "/models", authenticated=False, kind="liveness")
|
|
48
|
+
result["probes"]["api_rejects_anonymous"] = {"http_status": response.status, "latency_ms": round(response.elapsed * 1000)}
|
|
49
|
+
if response.status == 200:
|
|
50
|
+
result.update(_level("unhealthy", "auth", ["anonymous model listing was accepted"]))
|
|
51
|
+
elif response.status != 401:
|
|
52
|
+
result.update(_level("unhealthy", checks.http_category(response.status), ["unexpected HTTP %d" % response.status]))
|
|
53
|
+
page = dashboard.request("GET", "/management.html", authenticated=False, kind="liveness")
|
|
54
|
+
result["probes"]["dashboard"] = {"http_status": page.status, "latency_ms": round(page.elapsed * 1000)}
|
|
55
|
+
if page.status != 200 and result["status"] == "healthy":
|
|
56
|
+
result.update(_level("unhealthy", checks.http_category(page.status), ["dashboard returned HTTP %d" % page.status]))
|
|
57
|
+
except TransportError as error:
|
|
58
|
+
result.update(_level("unhealthy", error.category, [error.detail]))
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def readiness(config, base_url, key, redact, timeout, retries, backoff):
|
|
63
|
+
"""Authenticated and free: the key works, /v1/models is valid, every configured model id is served."""
|
|
64
|
+
if not key:
|
|
65
|
+
return _level("unknown", "credentials", ["no client API key available"]), set()
|
|
66
|
+
client = Client(base_url, key, redact, timeout)
|
|
67
|
+
response = None
|
|
68
|
+
for attempt in range(1 + retries): # read-only listing: the one place a transport retry is safe
|
|
69
|
+
try:
|
|
70
|
+
response = client.request("GET", "/models", kind="readiness")
|
|
71
|
+
break
|
|
72
|
+
except TransportError as error:
|
|
73
|
+
if attempt == retries:
|
|
74
|
+
return _level("unhealthy", error.category, [error.detail]), set()
|
|
75
|
+
time.sleep(backoff)
|
|
76
|
+
if response.status != 200:
|
|
77
|
+
return _level("unhealthy", checks.http_category(response.status),
|
|
78
|
+
["GET /models returned HTTP %d" % response.status], http_status=response.status), set()
|
|
79
|
+
body = response.json()
|
|
80
|
+
problems = protocol.models_list_violations(body)
|
|
81
|
+
if problems:
|
|
82
|
+
return _level("unhealthy", "protocol", problems), set()
|
|
83
|
+
served = {item["id"] for item in body["data"]}
|
|
84
|
+
configured = {m["id"] for m in config.models}
|
|
85
|
+
missing = sorted(configured - served)
|
|
86
|
+
return (
|
|
87
|
+
_level("unhealthy" if missing else "healthy", "model_missing" if missing else None,
|
|
88
|
+
["model not served: " + model_id for model_id in missing],
|
|
89
|
+
latency_ms=round(response.elapsed * 1000), served_models=len(served),
|
|
90
|
+
discovered_unconfigured=sorted(served - configured)),
|
|
91
|
+
served,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------- models
|
|
96
|
+
|
|
97
|
+
def _capability_rows(config, model, results, selected, model_known, model_present, aborted):
|
|
98
|
+
"""Per-capability truth table: declared state versus what was actually observed."""
|
|
99
|
+
by_id = {r["capability"]: r for r in results}
|
|
100
|
+
rows = {}
|
|
101
|
+
for case in config.cases:
|
|
102
|
+
declared, reason = config.state(model, case.capability)
|
|
103
|
+
row = {"declared": declared, "result": "untested", "reason": None, "severity": case.severity}
|
|
104
|
+
result = by_id.get(case.capability)
|
|
105
|
+
if declared == "unsupported":
|
|
106
|
+
row.update(result="unsupported", reason=reason)
|
|
107
|
+
elif result and result["status"] == "pass":
|
|
108
|
+
row["result"] = "pass"
|
|
109
|
+
elif result and result["status"] == "fail":
|
|
110
|
+
if declared == "optional" and result["category"] in ("assertion", "protocol", "request_rejected"):
|
|
111
|
+
row.update(result="observed_unsupported", reason="; ".join(result["details"])[:200])
|
|
112
|
+
else:
|
|
113
|
+
row.update(result="fail", reason=result["category"])
|
|
114
|
+
elif result:
|
|
115
|
+
row["reason"] = result["category"]
|
|
116
|
+
elif not model_known:
|
|
117
|
+
row["reason"] = "readiness_not_passed"
|
|
118
|
+
elif not model_present:
|
|
119
|
+
row["reason"] = "model_missing"
|
|
120
|
+
elif case.capability not in selected:
|
|
121
|
+
row["reason"] = "not_in_profile"
|
|
122
|
+
else:
|
|
123
|
+
row["reason"] = "paused_rate_limited" if (aborted or "").startswith("rate limited recently") else (
|
|
124
|
+
"aborted" if aborted else "not_run")
|
|
125
|
+
rows[case.capability] = row
|
|
126
|
+
return rows
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ratio(numerator, denominator):
|
|
130
|
+
return round(numerator / denominator, 4) if denominator else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _metrics(events, results, observed_models):
|
|
134
|
+
inference = [e for e in events if not e.get("probe")]
|
|
135
|
+
infra = [e for e in events if e["category"] in checks.INFRA_CATEGORIES]
|
|
136
|
+
streams = [e for e in inference if e["stream"] and not e["cancelled"]]
|
|
137
|
+
tools = [e for e in inference if e["tool"]]
|
|
138
|
+
totals = [round(e["total"] * 1000) for e in inference if e["total"] is not None and e["category"] is None]
|
|
139
|
+
ttfts = [round(e["ttft"] * 1000) for e in streams if e["ttft"] is not None]
|
|
140
|
+
categories = {}
|
|
141
|
+
for result in results:
|
|
142
|
+
if result["status"] == "fail":
|
|
143
|
+
categories[result["category"]] = categories.get(result["category"], 0) + 1
|
|
144
|
+
executed = [r for r in results if r["status"] in ("pass", "fail")]
|
|
145
|
+
return {
|
|
146
|
+
"requests": len(events),
|
|
147
|
+
"availability": _ratio(len(events) - len(infra), len(events)),
|
|
148
|
+
"success_rate": _ratio(sum(r["status"] == "pass" for r in executed), len(executed)),
|
|
149
|
+
"ttft_ms": {"p50": percentile(ttfts, 0.5), "max": max(ttfts) if ttfts else None, "samples": len(ttfts)},
|
|
150
|
+
"latency_ms": {"p50": percentile(totals, 0.5), "p95": percentile(totals, 0.95), "samples": len(totals)},
|
|
151
|
+
"streaming_completion_rate": _ratio(sum(e["completed"] for e in streams), len(streams)),
|
|
152
|
+
"tool_call_success_rate": _ratio(sum(e["ok"] for e in tools), len(tools)),
|
|
153
|
+
"error_categories": categories,
|
|
154
|
+
"upstream_failures": sum(e["category"] in ("upstream", "rate_limit", "timeout") for e in events),
|
|
155
|
+
"resolved_models": sorted(observed_models),
|
|
156
|
+
"routing": {"evidence": "none", "note": "Account routing is not observable from outside the proxy; see simulations."},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def derive_status(config, rows, results, metrics, model_known, model_present, scope_e2e):
|
|
161
|
+
"""Return (status, reasons). Unknown is never upgraded to healthy."""
|
|
162
|
+
policy = config.raw["health"]
|
|
163
|
+
if not model_known:
|
|
164
|
+
return "unknown", ["readiness did not pass; the model could not be confirmed"]
|
|
165
|
+
if not model_present:
|
|
166
|
+
return "unhealthy", ["model not served by /v1/models"]
|
|
167
|
+
if not scope_e2e:
|
|
168
|
+
return "unknown", ["model is served, but inference was not exercised at this scope"]
|
|
169
|
+
executed = [r for r in results if r["status"] in ("pass", "fail")]
|
|
170
|
+
if not executed:
|
|
171
|
+
return "unknown", ["no inference check completed"]
|
|
172
|
+
failing = [r for r in executed if rows[r["capability"]]["result"] == "fail"]
|
|
173
|
+
required = [r for r in executed if rows[r["capability"]]["declared"] == "required"]
|
|
174
|
+
required_failing = [r for r in failing if rows[r["capability"]]["declared"] == "required"]
|
|
175
|
+
critical = [r for r in required_failing if r["severity"] == "critical"]
|
|
176
|
+
if critical:
|
|
177
|
+
return "unhealthy", ["critical capability failing: " + critical[0]["capability"]]
|
|
178
|
+
if required and len(required_failing) / len(required) >= policy["unhealthy_if_failed_ratio"]:
|
|
179
|
+
return "unhealthy", ["%d of %d exercised required capabilities fail" % (len(required_failing), len(required))]
|
|
180
|
+
reasons = []
|
|
181
|
+
if failing:
|
|
182
|
+
reasons.append("failing: " + ", ".join(sorted(r["capability"] for r in failing)))
|
|
183
|
+
p50, p95 = metrics["ttft_ms"]["p50"], metrics["latency_ms"]["p95"]
|
|
184
|
+
if p50 is not None and p50 > policy["latency_ms"]["ttft_p50_warn"]:
|
|
185
|
+
reasons.append("time to first token p50 %dms exceeds policy" % p50)
|
|
186
|
+
if p95 is not None and p95 > policy["latency_ms"]["total_p95_warn"]:
|
|
187
|
+
reasons.append("latency p95 %dms exceeds policy" % p95)
|
|
188
|
+
if policy.get("model_consistency") and len(metrics["resolved_models"]) > 1:
|
|
189
|
+
reasons.append("model resolved to multiple ids: " + ", ".join(metrics["resolved_models"]))
|
|
190
|
+
return ("degraded" if reasons else "healthy"), reasons
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_model(config, model, cases, make_client, budget, seed, redact, timeouts, model_known, model_present, scope_e2e,
|
|
194
|
+
paused=None, paused_detail=None):
|
|
195
|
+
"""Test one model in isolation; any failure here is confined to this model."""
|
|
196
|
+
ctx = checks.Context(config, model, make_client, budget, random.Random("%s:%s" % (seed, model["id"])), redact, timeouts)
|
|
197
|
+
results, consecutive, aborted = [], 0, None
|
|
198
|
+
if paused:
|
|
199
|
+
aborted = paused # inference is skipped: the account was rate limited recently
|
|
200
|
+
if scope_e2e and model_known and model_present and not paused:
|
|
201
|
+
for case in sorted(cases, key=lambda c: SEVERITY_ORDER[c.severity]):
|
|
202
|
+
declared, _reason = config.state(model, case.capability)
|
|
203
|
+
if declared == "unsupported" or aborted:
|
|
204
|
+
continue
|
|
205
|
+
result = checks.execute(ctx, case)
|
|
206
|
+
result["declared"] = declared
|
|
207
|
+
results.append(result)
|
|
208
|
+
if result["status"] == "pass":
|
|
209
|
+
consecutive = 0
|
|
210
|
+
elif result["status"] == "fail":
|
|
211
|
+
consecutive += 1
|
|
212
|
+
if result["category"] == "rate_limit":
|
|
213
|
+
aborted = "rate limited; stopped to avoid hammering the account"
|
|
214
|
+
elif consecutive >= config.raw["health"]["abort_model_after_consecutive_failures"]:
|
|
215
|
+
aborted = "%d consecutive failures" % consecutive
|
|
216
|
+
rows = _capability_rows(config, model, results, {c.capability for c in cases}, model_known, model_present, aborted)
|
|
217
|
+
metrics = _metrics(ctx.events, results, ctx.observed_models)
|
|
218
|
+
status, reasons = derive_status(config, rows, results, metrics, model_known, model_present, scope_e2e)
|
|
219
|
+
if paused:
|
|
220
|
+
status, reasons = "unhealthy", ["paused: " + paused]
|
|
221
|
+
elif aborted:
|
|
222
|
+
reasons.append("aborted: " + aborted)
|
|
223
|
+
limit_error = next((r["details"][0] for r in results if r["status"] == "fail" and r["category"] == "rate_limit"), None)
|
|
224
|
+
rate_limited = bool(paused) or limit_error is not None
|
|
225
|
+
return {"id": model["id"], "provider": model["provider"], "status": status, "reasons": reasons,
|
|
226
|
+
"coverage": _coverage(rows), "capabilities": rows, "checks": results, "metrics": metrics,
|
|
227
|
+
"rate_limited": rate_limited, "paused": bool(paused), "rate_limit_detail": paused_detail if paused else limit_error}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _coverage(rows):
|
|
231
|
+
counts = {}
|
|
232
|
+
for row in rows.values():
|
|
233
|
+
counts[row["result"]] = counts.get(row["result"], 0) + 1
|
|
234
|
+
exercised = sum(r["result"] in ("pass", "fail", "observed_unsupported") for r in rows.values())
|
|
235
|
+
return {"capabilities": len(rows), "exercised": exercised, "by_result": counts}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# -------------------------------------------------------------------- state
|
|
239
|
+
|
|
240
|
+
def merge_state(report, previous, timestamp):
|
|
241
|
+
"""Carry forward last-success times; only passes ever advance them."""
|
|
242
|
+
state = {"models": {}}
|
|
243
|
+
for model in report["models"]:
|
|
244
|
+
old = (previous or {}).get("models", {}).get(model["id"], {})
|
|
245
|
+
capabilities = dict(old.get("capabilities", {}))
|
|
246
|
+
for capability, row in model["capabilities"].items():
|
|
247
|
+
if row["result"] == "pass":
|
|
248
|
+
capabilities[capability] = timestamp
|
|
249
|
+
passed = any(row["result"] == "pass" for row in model["capabilities"].values())
|
|
250
|
+
if model.get("paused"):
|
|
251
|
+
limited_at = old.get("rate_limited_at") # keep the original time so the pause expires
|
|
252
|
+
elif model.get("rate_limited"):
|
|
253
|
+
limited_at = timestamp
|
|
254
|
+
elif model["checks"]:
|
|
255
|
+
limited_at = None # inference ran without a 429: the quota is back
|
|
256
|
+
else:
|
|
257
|
+
limited_at = old.get("rate_limited_at")
|
|
258
|
+
detail = model.get("rate_limit_detail") if limited_at else None
|
|
259
|
+
entry = {"healthy_at": timestamp if model["status"] == "healthy" else old.get("healthy_at"),
|
|
260
|
+
"any_check_at": timestamp if passed else old.get("any_check_at"), "capabilities": capabilities,
|
|
261
|
+
"rate_limited_at": limited_at, "rate_limit_detail": detail}
|
|
262
|
+
state["models"][model["id"]] = entry
|
|
263
|
+
model["last_success"] = entry
|
|
264
|
+
return state
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_state(path):
|
|
268
|
+
try:
|
|
269
|
+
return json.loads(pathlib.Path(path).read_text())
|
|
270
|
+
except (OSError, ValueError, TypeError):
|
|
271
|
+
return {}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def save_state(path, state):
|
|
275
|
+
path = pathlib.Path(path)
|
|
276
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
temporary = path.with_name(path.name + ".new")
|
|
278
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
279
|
+
with os.fdopen(descriptor, "w") as handle:
|
|
280
|
+
json.dump(state, handle, indent=2, sort_keys=True)
|
|
281
|
+
os.replace(temporary, path)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------- main
|
|
285
|
+
|
|
286
|
+
def run(config, run_name, suite="all", base_url=None, key=None, target="live", model_ids=None, seed=None,
|
|
287
|
+
state_path=None, overrides=None, simulate=None, clock=now_iso, only=None, force_probe=False):
|
|
288
|
+
"""Execute a run profile and return the structured report."""
|
|
289
|
+
overrides = overrides or {}
|
|
290
|
+
profile = config.runs(run_name)
|
|
291
|
+
base_url = base_url or config.raw["endpoint"]["base_url"]
|
|
292
|
+
redact = Redactor([key])
|
|
293
|
+
timeouts = dict(config.raw["timeouts"])
|
|
294
|
+
if overrides.get("timeout_scale"):
|
|
295
|
+
timeouts = {k: v * overrides["timeout_scale"] for k, v in timeouts.items()}
|
|
296
|
+
seed = seed if seed is not None else random.SystemRandom().randrange(1 << 30)
|
|
297
|
+
unknown = sorted(set(model_ids or []) - {m["id"] for m in config.models})
|
|
298
|
+
if unknown:
|
|
299
|
+
raise ValueError("Unknown model id requested: " + ", ".join(unknown))
|
|
300
|
+
models = [m for m in config.models if not model_ids or m["id"] in model_ids]
|
|
301
|
+
report = {"schema": SCHEMA, "generated_at": clock(), "profile": run_name, "suite": suite,
|
|
302
|
+
"target": {"kind": target, "base_url": base_url}, "seed": seed, "levels": {}, "models": [],
|
|
303
|
+
"cursor": dict(config.raw["cursor"], suite_selected=suite in ("cursor", "all")),
|
|
304
|
+
"routing": {"declared": {"strategy": config.raw["routing"]["strategy"],
|
|
305
|
+
"session_affinity": config.raw["routing"]["session_affinity"]},
|
|
306
|
+
"live_verified": False,
|
|
307
|
+
"note": "Declared policy only: the live proxy's strategy is not observable through the API."}}
|
|
308
|
+
live = liveness(base_url, redact, timeouts["liveness_s"])
|
|
309
|
+
report["levels"]["liveness"] = live
|
|
310
|
+
served, ready = set(), None
|
|
311
|
+
if "readiness" in profile["levels"]:
|
|
312
|
+
if live["status"] == "unhealthy" and live["category"] in ("transport", "timeout"):
|
|
313
|
+
ready = _level("unknown", live["category"], ["endpoint unreachable; readiness not attempted"])
|
|
314
|
+
else:
|
|
315
|
+
retry = config.raw["retry"]
|
|
316
|
+
ready, served = readiness(config, base_url, key, redact, timeouts["request_s"], retry["read_only"], retry["backoff_s"])
|
|
317
|
+
report["levels"]["readiness"] = ready
|
|
318
|
+
model_known = ready is not None and (ready["status"] == "healthy" or ready["category"] == "model_missing")
|
|
319
|
+
scope_e2e = "e2e" in profile["levels"]
|
|
320
|
+
cases = config.cases_for(suite, profile["max_tier"], only) if scope_e2e else []
|
|
321
|
+
previous = load_state(state_path) if state_path else {}
|
|
322
|
+
pause = config.raw["health"]["rate_limit_backoff_s"]
|
|
323
|
+
paused = {}
|
|
324
|
+
for model in models:
|
|
325
|
+
limited_at = previous.get("models", {}).get(model["id"], {}).get("rate_limited_at")
|
|
326
|
+
if scope_e2e and limited_at and not force_probe:
|
|
327
|
+
resume = _parse(limited_at) + datetime.timedelta(seconds=pause)
|
|
328
|
+
if _parse(report["generated_at"]) < resume:
|
|
329
|
+
paused[model["id"]] = "rate limited recently (at %s); not probing again before %s so no quota is spent. Use --force-probe to override." % (
|
|
330
|
+
limited_at, resume.replace(microsecond=0).isoformat())
|
|
331
|
+
limits = dict(profile.get("budget", {"max_requests": 1, "max_requests_per_model": 1, "max_output_tokens": 1, "concurrency": 1}))
|
|
332
|
+
limits.update({k: v for k, v in overrides.items() if k in limits and v})
|
|
333
|
+
budget = checks.Budget(limits["max_requests"], limits["max_requests_per_model"], limits["max_output_tokens"])
|
|
334
|
+
|
|
335
|
+
def factory(model_id):
|
|
336
|
+
def on_request(kind):
|
|
337
|
+
if kind in ("inference", "probe"):
|
|
338
|
+
budget.take(model_id)
|
|
339
|
+
|
|
340
|
+
return Client(base_url, key, redact, timeouts["request_s"], on_request)
|
|
341
|
+
|
|
342
|
+
def one(model):
|
|
343
|
+
last = previous.get("models", {}).get(model["id"], {}).get("rate_limit_detail")
|
|
344
|
+
return run_model(config, model, cases, factory, budget, seed, redact, timeouts,
|
|
345
|
+
model_known, model["id"] in served, scope_e2e, paused.get(model["id"]), last)
|
|
346
|
+
|
|
347
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=limits["concurrency"]) as pool:
|
|
348
|
+
report["models"] = list(pool.map(one, models))
|
|
349
|
+
if scope_e2e:
|
|
350
|
+
report["budget"] = {"requests_used": budget.used, "requests_max": limits["max_requests"],
|
|
351
|
+
"output_tokens_used": budget.tokens, "output_tokens_max": limits["max_output_tokens"],
|
|
352
|
+
"per_model_max": limits["max_requests_per_model"], "concurrency": limits["concurrency"],
|
|
353
|
+
"inference_retries": config.raw["retry"]["inference"]}
|
|
354
|
+
report["simulations"] = simulate() if simulate and profile.get("simulations") else []
|
|
355
|
+
state = merge_state(report, previous, report["generated_at"])
|
|
356
|
+
report["summary"] = summarize(report, profile)
|
|
357
|
+
report["findings"] = findings.build(config, report, run_name)
|
|
358
|
+
report["summary"]["findings"] = {
|
|
359
|
+
"total": len(report["findings"]),
|
|
360
|
+
"operator": sum(not f["agent_fixable"] for f in report["findings"]),
|
|
361
|
+
"agent": sum(f["agent_fixable"] for f in report["findings"]),
|
|
362
|
+
"fingerprint": findings.fingerprint(report["findings"]),
|
|
363
|
+
}
|
|
364
|
+
if state_path:
|
|
365
|
+
save_state(state_path, state)
|
|
366
|
+
return report
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def summarize(report, profile):
|
|
370
|
+
levels = {name: level["status"] for name, level in report["levels"].items()}
|
|
371
|
+
models = {m["id"]: m["status"] for m in report["models"]}
|
|
372
|
+
failed_sims = [s["id"] for s in report["simulations"] if s["status"] != "pass"]
|
|
373
|
+
scope_e2e = "e2e" in profile["levels"]
|
|
374
|
+
# A 429 or an active pause means the proxy answered and the provider account is out of quota. That is
|
|
375
|
+
# the operator's to resolve and clears itself, so it is reported but never fails the gate.
|
|
376
|
+
rate_limited = sorted(m["id"] for m in report["models"] if m["rate_limited"])
|
|
377
|
+
judged_models = {mid: s for mid, s in models.items() if mid not in rate_limited}
|
|
378
|
+
judged = list(levels.values()) + (list(judged_models.values()) if scope_e2e else
|
|
379
|
+
[s for s in judged_models.values() if s == "unhealthy"])
|
|
380
|
+
top = "readiness" if "readiness" in levels else "liveness"
|
|
381
|
+
if any(s == "unhealthy" for s in judged):
|
|
382
|
+
overall = "unhealthy"
|
|
383
|
+
elif any(s == "unknown" for s in judged) and (scope_e2e or levels[top] == "unknown"):
|
|
384
|
+
overall = "unknown"
|
|
385
|
+
elif any(s == "degraded" for s in judged) or failed_sims:
|
|
386
|
+
overall = "degraded"
|
|
387
|
+
else:
|
|
388
|
+
overall = "healthy"
|
|
389
|
+
return {"status": overall, "levels": levels, "models": models, "rate_limited": rate_limited,
|
|
390
|
+
"simulation_failures": failed_sims}
|