@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.
Files changed (333) hide show
  1. package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
  2. package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
  3. package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
  4. package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
  5. package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
  6. package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
  7. package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
  8. package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
  9. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
  10. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
  11. package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
  12. package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
  13. package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
  14. package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
  15. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
  16. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
  17. package/LICENSE +21 -0
  18. package/README.md +157 -2
  19. package/agents/antigravity-proxy.ts +3 -0
  20. package/agents/audit.md +89 -0
  21. package/agents/claude-proxy +5 -0
  22. package/agents/claude-proxy.ts +3 -0
  23. package/agents/codex-proxy.ts +3 -0
  24. package/agents/grok-proxy.ts +3 -0
  25. package/agents/managed-proxy.ts +55 -0
  26. package/agents/opencode-proxy.ts +3 -0
  27. package/agents/registry.json +209 -0
  28. package/agents/registry.schema.json +149 -0
  29. package/agents/setup.md +128 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +17 -0
  33. package/ansible/monitoring/README.md +154 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +134 -0
  36. package/ansible/monitoring/beszel-agent.service +42 -0
  37. package/ansible/monitoring/compose.yml +74 -0
  38. package/ansible/monitoring/hub-darwin.yml +102 -0
  39. package/ansible/monitoring/lock.json +11 -0
  40. package/ansible/net/remote-probe.sh +13 -0
  41. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  42. package/ansible/ubuntu/baseline.yml +89 -0
  43. package/ansible/ubuntu/disk-guard.py +104 -0
  44. package/ansible/ubuntu/policy.json +43 -0
  45. package/ansible/ubuntu/recovery.yml +15 -0
  46. package/ansible/ubuntu/t3/policy.json +7 -0
  47. package/ansible/ubuntu/t3-desktop.yml +220 -0
  48. package/ansible/ubuntu/t3-health.sh +33 -0
  49. package/ansible/ubuntu/t3-policy.yml +76 -0
  50. package/ansible/ubuntu/t3-route.yml +28 -0
  51. package/ansible/ubuntu/tailscale.yml +75 -0
  52. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  53. package/ansible/ubuntu/tasks/docker.yml +31 -0
  54. package/ansible/ubuntu/tasks/packages.yml +56 -0
  55. package/ansible/ubuntu/tasks/power.yml +51 -0
  56. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  57. package/ansible/ubuntu/tasks/resources.yml +252 -0
  58. package/ansible/ubuntu/tasks/services.yml +31 -0
  59. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  60. package/ansible/ubuntu/tasks/user.yml +120 -0
  61. package/bin/devfleet.mjs +36 -0
  62. package/bootstrap/bootstrap.ps1 +50 -0
  63. package/bootstrap/bootstrap.sh +73 -0
  64. package/bootstrap/devfleet +7 -0
  65. package/bootstrap/docker-desktop.sh +33 -0
  66. package/bootstrap/doctor.ts +189 -0
  67. package/bootstrap/fleet-manager-run.sh +76 -0
  68. package/bootstrap/fleet-remote-bootstrap.sh +185 -0
  69. package/bootstrap/global-config.ts +113 -0
  70. package/bootstrap/install-launcher.sh +7 -0
  71. package/bootstrap/native-aws.ts +86 -0
  72. package/bootstrap/native-t3.ts +154 -0
  73. package/bootstrap/npm.ts +350 -0
  74. package/bootstrap/setup-status.ts +51 -0
  75. package/bootstrap/setup.ts +88 -0
  76. package/bootstrap/shell-path.ts +105 -0
  77. package/bootstrap/verify-t3-archive.py +49 -0
  78. package/bootstrap/worker-auth.ts +65 -0
  79. package/bootstrap/worker-github.ts +47 -0
  80. package/bootstrap/worker-tools.ts +24 -0
  81. package/cliproxy/AGENTS.md +11 -0
  82. package/cliproxy/README.md +269 -0
  83. package/cliproxy/clients/bootstrap.py +143 -0
  84. package/cliproxy/clients/launcher.py +57 -0
  85. package/cliproxy/compose.yaml +90 -0
  86. package/cliproxy/config.template.yaml +70 -0
  87. package/cliproxy/docs/cloud-adoption.md +16 -0
  88. package/cliproxy/docs/deployment.png +0 -0
  89. package/cliproxy/docs/deployment.svg +109 -0
  90. package/cliproxy/docs/health-workflow.example.yml +185 -0
  91. package/cliproxy/docs/migration.md +131 -0
  92. package/cliproxy/docs/model-health-report.schema.json +364 -0
  93. package/cliproxy/docs/model-health.md +393 -0
  94. package/cliproxy/image.lock.json +5 -0
  95. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  96. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  97. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  98. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  99. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  101. package/cliproxy/models.json +1618 -0
  102. package/cliproxy/requirements.lock +78 -0
  103. package/cliproxy/scripts/accounts.py +38 -0
  104. package/cliproxy/scripts/audit.py +123 -0
  105. package/cliproxy/scripts/backup +234 -0
  106. package/cliproxy/scripts/check.py +34 -0
  107. package/cliproxy/scripts/checkpoint.py +226 -0
  108. package/cliproxy/scripts/cli.py +64 -0
  109. package/cliproxy/scripts/cliproxy_host.py +257 -0
  110. package/cliproxy/scripts/cloud_state.py +150 -0
  111. package/cliproxy/scripts/container-health +6 -0
  112. package/cliproxy/scripts/docker +24 -0
  113. package/cliproxy/scripts/external-health.py +79 -0
  114. package/cliproxy/scripts/fleet_config.py +43 -0
  115. package/cliproxy/scripts/fleet_sync.py +274 -0
  116. package/cliproxy/scripts/initialize +84 -0
  117. package/cliproxy/scripts/install-dependencies +48 -0
  118. package/cliproxy/scripts/install.py +406 -0
  119. package/cliproxy/scripts/model_health.py +16 -0
  120. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  121. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  122. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  123. package/cliproxy/scripts/modelhealth/config.py +186 -0
  124. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  125. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  126. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  127. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  128. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  129. package/cliproxy/scripts/modelhealth/report.py +150 -0
  130. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  131. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  132. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  133. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  134. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  135. package/cliproxy/scripts/monitor +68 -0
  136. package/cliproxy/scripts/ops +527 -0
  137. package/cliproxy/scripts/public-endpoint +127 -0
  138. package/cliproxy/scripts/recover_node.py +177 -0
  139. package/cliproxy/scripts/restore-verify +60 -0
  140. package/cliproxy/scripts/retention.py +144 -0
  141. package/cliproxy/scripts/service-start +7 -0
  142. package/cliproxy/scripts/validate-config +56 -0
  143. package/cliproxy/scripts/vault-access.py +57 -0
  144. package/cliproxy/scripts/vault-backup-login +55 -0
  145. package/cliproxy/scripts/vault-recovery +56 -0
  146. package/cliproxy/scripts/vault-renew +30 -0
  147. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  148. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  149. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  150. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  151. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  152. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  153. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  155. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  156. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  157. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  158. package/cliproxy/vendor/LICENSE +22 -0
  159. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  160. package/examples/fleet.json +95 -0
  161. package/examples/roster.json +13 -0
  162. package/mise.lock +1492 -0
  163. package/mise.toml +99 -0
  164. package/package.json +65 -4
  165. package/schema/fleet.schema.json +364 -0
  166. package/schema/roster.schema.json +77 -0
  167. package/src/agents/account-relay.ts +111 -0
  168. package/src/agents/accounts.ts +85 -0
  169. package/src/agents/audit.ts +77 -0
  170. package/src/agents/cli.ts +13 -0
  171. package/src/agents/command.ts +204 -0
  172. package/src/agents/converge.ts +123 -0
  173. package/src/agents/doctor.ts +107 -0
  174. package/src/agents/errors.ts +2 -0
  175. package/src/agents/files.ts +73 -0
  176. package/src/agents/fleet-verify.ts +156 -0
  177. package/src/agents/launch.ts +290 -0
  178. package/src/agents/native-verify.ts +226 -0
  179. package/src/agents/provider-errors.ts +50 -0
  180. package/src/agents/registry.ts +55 -0
  181. package/src/agents/render.ts +110 -0
  182. package/src/agents/round-trip.ts +192 -0
  183. package/src/agents/secrets.ts +13 -0
  184. package/src/agents/toolchain.ts +34 -0
  185. package/src/agents/verify-cache.ts +103 -0
  186. package/src/agents/verify.ts +127 -0
  187. package/src/auth-checks.ts +163 -0
  188. package/src/cli/agent-guide.ts +95 -0
  189. package/src/cli/app.ts +115 -0
  190. package/src/cli/cli.ts +21 -0
  191. package/src/cli/command-index.ts +50 -0
  192. package/src/cli/contracts.ts +25 -0
  193. package/src/cli/fleet-reference.ts +17 -0
  194. package/src/cli/fleet-sync-checkout.ts +91 -0
  195. package/src/cli/fleet-sync-enrollment.ts +144 -0
  196. package/src/cli/fleet-sync-forward.ts +134 -0
  197. package/src/cli/fleet-sync-manager.ts +193 -0
  198. package/src/cli/fleet-sync-monitoring.ts +185 -0
  199. package/src/cli/fleet-sync-output.ts +314 -0
  200. package/src/cli/fleet-sync-privilege.ts +27 -0
  201. package/src/cli/fleet-sync-progress.ts +109 -0
  202. package/src/cli/fleet-sync-remote.ts +236 -0
  203. package/src/cli/fleet-sync-report.ts +26 -0
  204. package/src/cli/fleet-sync-schedule.ts +138 -0
  205. package/src/cli/fleet-sync-vault.ts +166 -0
  206. package/src/cli/fleet-sync.ts +309 -0
  207. package/src/cli/fleet.ts +163 -0
  208. package/src/cli/managed-health.ts +219 -0
  209. package/src/cli/node-deployment.ts +34 -0
  210. package/src/cli/node-sync.ts +114 -0
  211. package/src/cli/node-t3.ts +31 -0
  212. package/src/cli/options.ts +141 -0
  213. package/src/cli/probe-source.ts +7 -0
  214. package/src/cli/program.ts +169 -0
  215. package/src/cli/register-fleet.ts +246 -0
  216. package/src/cli/registry.ts +158 -0
  217. package/src/cli/repair-apply.ts +67 -0
  218. package/src/cli/repair-guard.ts +11 -0
  219. package/src/cli/repair-inspect.ts +150 -0
  220. package/src/cli/repair-script.ts +76 -0
  221. package/src/cli/repair.ts +209 -0
  222. package/src/cli/roster.ts +89 -0
  223. package/src/cli/search.ts +35 -0
  224. package/src/cli/settings.ts +64 -0
  225. package/src/cli/source-operations.ts +78 -0
  226. package/src/cli/t3/command.ts +90 -0
  227. package/src/cli/t3/register.ts +55 -0
  228. package/src/cli/tailnet.ts +127 -0
  229. package/src/cli/ui-actions.ts +133 -0
  230. package/src/cli/ui-controller.ts +113 -0
  231. package/src/cli/ui-data.ts +106 -0
  232. package/src/cli/ui-execute.ts +163 -0
  233. package/src/cli/ui-repairs.ts +34 -0
  234. package/src/cli/ui-reports.ts +211 -0
  235. package/src/cli/ui-targets.ts +100 -0
  236. package/src/cli/ui-terminal.ts +109 -0
  237. package/src/cli/ui-types.ts +49 -0
  238. package/src/cli/ui-wizard.ts +101 -0
  239. package/src/cli/ui.ts +9 -0
  240. package/src/cli/worker-health-output.ts +37 -0
  241. package/src/config.ts +186 -0
  242. package/src/configure.ts +255 -0
  243. package/src/definition.ts +260 -0
  244. package/src/diagnostics.ts +92 -0
  245. package/src/doctor-check.ts +10 -0
  246. package/src/errors.ts +1 -0
  247. package/src/fingerprint.ts +120 -0
  248. package/src/fleet-config.ts +88 -0
  249. package/src/index.ts +6 -0
  250. package/src/input-hashes.ts +73 -0
  251. package/src/machine/local-source.ts +52 -0
  252. package/src/machine/lock-error.ts +2 -0
  253. package/src/machine/platform.ts +44 -0
  254. package/src/machine/source.ts +68 -0
  255. package/src/machine/state.ts +185 -0
  256. package/src/machine-health.ts +95 -0
  257. package/src/managed-source.ts +47 -0
  258. package/src/manager-failure.ts +48 -0
  259. package/src/manager-sync.ts +400 -0
  260. package/src/manager.ts +62 -0
  261. package/src/monitoring/apply.ts +171 -0
  262. package/src/monitoring/beszel.ts +191 -0
  263. package/src/monitoring/enroll.ts +110 -0
  264. package/src/monitoring/hub-access.ts +113 -0
  265. package/src/monitoring/hub-viewer.ts +142 -0
  266. package/src/monitoring/hub.ts +93 -0
  267. package/src/monitoring/manager-agent.ts +73 -0
  268. package/src/monitoring/manager-hub.ts +232 -0
  269. package/src/monitoring/remote.ts +65 -0
  270. package/src/network-definition.ts +83 -0
  271. package/src/node-converge-linux.ts +372 -0
  272. package/src/node-converge-shared.ts +83 -0
  273. package/src/node-converge.ts +40 -0
  274. package/src/onboard.ts +35 -0
  275. package/src/platform/ansible-receipt.ts +128 -0
  276. package/src/platform/ansible.ts +62 -0
  277. package/src/platform/darwin.ts +203 -0
  278. package/src/platform/detect.ts +11 -0
  279. package/src/platform/fallback.ts +110 -0
  280. package/src/platform/index.ts +38 -0
  281. package/src/platform/launchd.ts +98 -0
  282. package/src/platform/linux.ts +139 -0
  283. package/src/platform/ps.ts +45 -0
  284. package/src/platform/resource-verify.ts +128 -0
  285. package/src/platform/systemd-user.ts +95 -0
  286. package/src/platform/t3-policy.ts +137 -0
  287. package/src/platform/t3-route.ts +68 -0
  288. package/src/platform/t3-service.ts +75 -0
  289. package/src/platform/types.ts +123 -0
  290. package/src/platform/ubuntu-audit.ts +63 -0
  291. package/src/platform/ubuntu-verify.ts +105 -0
  292. package/src/platform/windows.ts +148 -0
  293. package/src/power-recovery.ts +55 -0
  294. package/src/probe.ts +51 -0
  295. package/src/protocol.ts +119 -0
  296. package/src/proxy-converge.ts +85 -0
  297. package/src/remote-bootstrap.ts +212 -0
  298. package/src/remote-entry.ts +89 -0
  299. package/src/remote-lock.ts +12 -0
  300. package/src/repair-catalog.ts +206 -0
  301. package/src/repair-plan.ts +78 -0
  302. package/src/repair-runner.ts +84 -0
  303. package/src/report.ts +71 -0
  304. package/src/runner.ts +104 -0
  305. package/src/secrets.ts +169 -0
  306. package/src/service-auth.ts +115 -0
  307. package/src/ssh.ts +135 -0
  308. package/src/sync.ts +104 -0
  309. package/src/t3/accounts.ts +81 -0
  310. package/src/t3/desired.ts +165 -0
  311. package/src/t3/files.ts +81 -0
  312. package/src/t3/names-check.ts +53 -0
  313. package/src/t3/names.ts +155 -0
  314. package/src/t3/plan.ts +82 -0
  315. package/src/t3/providers.ts +74 -0
  316. package/src/t3/reconcile.ts +123 -0
  317. package/src/t3-endpoint.ts +48 -0
  318. package/src/t3-linked-check.ts +101 -0
  319. package/src/tailnet-health.ts +231 -0
  320. package/src/tailnet-matching.ts +37 -0
  321. package/src/tailscale.ts +141 -0
  322. package/src/test-fixtures.ts +36 -0
  323. package/src/toolchain-checks.ts +83 -0
  324. package/src/topology.ts +24 -0
  325. package/src/transport.ts +122 -0
  326. package/src/types.ts +95 -0
  327. package/src/vault-config.ts +38 -0
  328. package/src/worker-access-checks.ts +115 -0
  329. package/src/worker-capacity.ts +34 -0
  330. package/src/worker-host-checks.ts +176 -0
  331. package/src/worker-policy.ts +64 -0
  332. package/src/worker-readiness.ts +64 -0
  333. 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}