@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,429 @@
1
+ """Generic check engines. Model- and provider-specific behavior lives only in models.json."""
2
+
3
+ import concurrent.futures
4
+ import copy
5
+ import time
6
+
7
+ from . import expect as expectations
8
+ from . import protocol, scenario
9
+ from .transport import Client, TransportError
10
+
11
+ INFRA_CATEGORIES = ("auth", "rate_limit", "timeout", "transport", "upstream", "not_found")
12
+
13
+
14
+ class BudgetExceeded(Exception):
15
+ pass
16
+
17
+
18
+ class Budget:
19
+ """Thread-safe request and output-token ceilings for one run."""
20
+
21
+ def __init__(self, max_requests, per_model, max_output_tokens):
22
+ import threading
23
+
24
+ self.max_requests, self.per_model, self.max_tokens = max_requests, per_model, max_output_tokens
25
+ self.used, self.tokens, self.by_model = 0, 0, {}
26
+ self.lock = threading.Lock()
27
+
28
+ def take(self, model_id):
29
+ with self.lock:
30
+ if self.used >= self.max_requests:
31
+ raise BudgetExceeded("run request budget exhausted")
32
+ if self.by_model.get(model_id, 0) >= self.per_model:
33
+ raise BudgetExceeded("per-model request budget exhausted")
34
+ if self.tokens >= self.max_tokens:
35
+ raise BudgetExceeded("output token budget exhausted")
36
+ self.used += 1
37
+ self.by_model[model_id] = self.by_model.get(model_id, 0) + 1
38
+
39
+ def spend(self, tokens):
40
+ with self.lock:
41
+ self.tokens += tokens or 0
42
+
43
+
44
+ class Outcome:
45
+ def __init__(self):
46
+ self.norm = None
47
+ self.category = None
48
+ self.detail = None
49
+ self.status = 0
50
+ self.elapsed = 0.0
51
+ self.ttft = None
52
+ self.max_gap = None
53
+ self.retry_after = None
54
+ self.event = None
55
+ self.stream_result = None
56
+ self.headers = {}
57
+
58
+ @property
59
+ def failed(self):
60
+ return self.category is not None
61
+
62
+
63
+ def http_category(status):
64
+ if status in (401, 403):
65
+ return "auth"
66
+ if status == 404:
67
+ return "not_found"
68
+ if status == 429:
69
+ return "rate_limit"
70
+ if status in (408, 504):
71
+ return "timeout"
72
+ if status >= 500:
73
+ return "upstream"
74
+ if status in (400, 413, 422):
75
+ return "request_rejected"
76
+ return "protocol"
77
+
78
+
79
+ class Context:
80
+ """Everything a check needs for one model; no cross-model mutable state."""
81
+
82
+ def __init__(self, config, model, client_factory, budget, rng, redact, timeouts):
83
+ self.config, self.model, self.model_id = config, model, model["id"]
84
+ self.make_client, self.budget, self.rng, self.redact = client_factory, budget, rng, redact
85
+ self.timeouts = timeouts
86
+ self.client = client_factory(self.model_id)
87
+ self.events = []
88
+ self.traces = [] # per-request evidence for failure reports; never contains prompt text
89
+ self.observed_models = set()
90
+
91
+ def token(self):
92
+ letters = "".join(self.rng.choice("ABCDEFGHJKLMNPQRSTUVWXYZ") for _ in range(3))
93
+ return "%s-%04d" % (letters, self.rng.randrange(10000))
94
+
95
+ def variables(self):
96
+ return {"model_id": self.model_id, "nonce": self.token(), "codeword": self.token(), "marker": self.token()}
97
+
98
+
99
+ def _envelope_detail(response, ctx):
100
+ body = response.json()
101
+ if isinstance(body, dict) and isinstance(body.get("error"), dict):
102
+ error = body["error"]
103
+ return ctx.redact("%s %s: %s" % (error.get("type", ""), error.get("code", ""), error.get("message", "")), 160)
104
+ return "no error envelope"
105
+
106
+
107
+ def call(ctx, case_protocol, turn, variables, stop_after=None, model_id=None, headers=None):
108
+ """Send one request and return an Outcome (never raises for HTTP/protocol failures)."""
109
+ out = Outcome()
110
+ config = ctx.config.raw
111
+ body = scenario.build(case_protocol, model_id or ctx.model_id, turn, config, ctx.config.extra(ctx.model, case_protocol))
112
+ path = config["protocols"][case_protocol]["path"]
113
+ stream = bool(turn.get("stream"))
114
+ event = {"protocol": case_protocol, "stream": stream, "tool": bool(turn.get("tools")), "ok": False,
115
+ "category": None, "completed": False, "cancelled": False, "status": 0, "ttft": None, "total": None}
116
+ out.event = event
117
+ ctx.events.append(event)
118
+ try:
119
+ if stream:
120
+ result = ctx.client.stream(path, body, timeout=ctx.timeouts["stream_idle_s"],
121
+ deadline=ctx.timeouts["stream_deadline_s"], stop_after=stop_after,
122
+ headers=headers)
123
+ out.stream_result = result
124
+ out.headers = result.headers
125
+ out.status, out.elapsed, out.ttft = result.status, result.elapsed, result.ttft
126
+ times = [f.at for f in result.frames]
127
+ out.max_gap = max([b - a for a, b in zip(times, times[1:])] or [0.0])
128
+ if result.status != 200:
129
+ out.category = http_category(result.status)
130
+ out.detail = "HTTP %d" % result.status
131
+ return out
132
+ if result.cancelled:
133
+ event["cancelled"] = True
134
+ return out
135
+ out.norm = (protocol.chat_stream if case_protocol == "chat" else protocol.responses_stream)(result)
136
+ event["completed"] = out.norm.terminated and not out.norm.violations and out.norm.upstream_error is None
137
+ else:
138
+ response = ctx.client.request("POST", path, body, timeout=ctx.timeouts["request_s"], kind="inference",
139
+ headers=headers)
140
+ out.status, out.elapsed = response.status, response.elapsed
141
+ out.retry_after = response.headers.get("retry-after")
142
+ out.headers = response.headers
143
+ if response.status != 200:
144
+ out.category = http_category(response.status)
145
+ out.detail = "HTTP %d %s" % (response.status, _envelope_detail(response, ctx))
146
+ return out
147
+ payload = response.json()
148
+ if payload is None:
149
+ out.category, out.detail = "protocol", "response body is not JSON"
150
+ return out
151
+ out.norm = (protocol.chat_response if case_protocol == "chat" else protocol.responses_response)(payload)
152
+ except TransportError as error:
153
+ out.category, out.detail = error.category, error.detail
154
+ except BudgetExceeded:
155
+ ctx.events.remove(event) # nothing was sent, so it is not a sample
156
+ raise
157
+ finally:
158
+ ctx.traces.append(_trace(ctx, case_protocol, path, turn, out, stream))
159
+ event.update(status=out.status, ttft=out.ttft, total=out.elapsed, category=out.category)
160
+ if out.norm is not None:
161
+ if out.norm.model:
162
+ ctx.observed_models.add(out.norm.model)
163
+ if out.norm.usage:
164
+ ctx.budget.spend(out.norm.usage["output"])
165
+ return out
166
+
167
+
168
+ def _choice_kind(choice):
169
+ if isinstance(choice, dict):
170
+ return "forced:" + str(choice.get("name"))
171
+ return choice
172
+
173
+
174
+ def _trace(ctx, case_protocol, path, turn, out, stream):
175
+ """Structural evidence about one request/response: shapes and numbers, never message text."""
176
+ norm = out.norm
177
+ headers = out.headers or {}
178
+ trace = {
179
+ "protocol": case_protocol, "method": "POST", "path": path, "stream": stream,
180
+ "request": {"tools": list(turn.get("tools", [])), "tool_choice": _choice_kind(turn.get("tool_choice")),
181
+ "max_tokens": turn.get("max_tokens"), "json_schema": bool(turn.get("json_schema")),
182
+ "messages": len(turn.get("messages", []))},
183
+ "http_status": out.status or None, "category": out.category,
184
+ "total_ms": round(out.elapsed * 1000), "ttft_ms": round(out.ttft * 1000) if out.ttft is not None else None,
185
+ "retry_after": out.retry_after,
186
+ "cf_ray": headers.get("cf-ray"), "request_id": headers.get("x-request-id"),
187
+ }
188
+ if out.failed and out.detail:
189
+ trace["error"] = ctx.redact(out.detail, 240)
190
+ if norm is not None:
191
+ trace["response"] = {
192
+ "model": norm.model, "finish_reason": norm.finish_reason, "events": norm.events or None,
193
+ "content_chars": len(norm.content), "tool_calls": [c["name"] for c in norm.tool_calls],
194
+ "usage": norm.usage, "terminated": norm.terminated,
195
+ "violations": [ctx.redact(v, 160) for v in norm.violations[:6]],
196
+ }
197
+ return trace
198
+
199
+
200
+ def judge(ctx, out, expect, variables, tool_names=()):
201
+ """Return (category, [messages]) or (None, [])."""
202
+ if out.failed:
203
+ return out.category, [out.detail]
204
+ schemas = scenario.tool_schemas(ctx.config.raw, tool_names)
205
+ issues = expectations.evaluate(scenario.substitute(expect, variables), out.norm, schemas, out.max_gap)
206
+ out.event["ok"] = not issues
207
+ if issues:
208
+ out.event["category"] = issues[0][0]
209
+ return issues[0][0], [ctx.redact(message) for _category, message in issues[:6]]
210
+ return None, []
211
+
212
+
213
+ class Verdict:
214
+ def __init__(self, category=None, details=None, **observations):
215
+ self.category, self.details, self.observations = category, details or [], observations
216
+
217
+ @property
218
+ def passed(self):
219
+ return self.category is None
220
+
221
+
222
+ def _observe(out):
223
+ seen = {}
224
+ if out.norm is not None:
225
+ seen["resolved_model"] = out.norm.model
226
+ if out.norm.usage:
227
+ seen["usage"] = out.norm.usage
228
+ seen["finish_reason"] = out.norm.finish_reason
229
+ seen["tool_calls"] = len(out.norm.tool_calls)
230
+ if out.norm.stream:
231
+ seen["stream_events"] = out.norm.events
232
+ return seen
233
+
234
+
235
+ def _turn(case, ctx, variables):
236
+ return scenario.substitute(copy.deepcopy(case.check["turn"]), variables)
237
+
238
+
239
+ def run_request(ctx, case, variables):
240
+ turn = _turn(case, ctx, variables)
241
+ out = call(ctx, case.protocol, turn, variables)
242
+ category, details = judge(ctx, out, case.check["expect"], variables, turn.get("tools", []))
243
+ return Verdict(category, details, _out=out, **_observe(out))
244
+
245
+
246
+ def _tool_turn(turn, norm, results, variables):
247
+ calls = [{"id": c["id"], "name": c["name"], "arguments": c["arguments"] or "{}"} for c in norm.tool_calls]
248
+ messages = list(turn["messages"]) + [{"role": "assistant", "content": norm.content or None, "tool_calls": calls}]
249
+ for call_ in norm.tool_calls:
250
+ messages.append({"role": "tool", "tool_call_id": call_["id"], "content": results[call_["name"]]})
251
+ return messages
252
+
253
+
254
+ def run_roundtrip(ctx, case, variables):
255
+ turn = _turn(case, ctx, variables)
256
+ first = call(ctx, case.protocol, turn, variables)
257
+ category, details = judge(ctx, first, case.check["expect"], variables, turn.get("tools", []))
258
+ if category:
259
+ return Verdict(category, ["step 1: " + d for d in details], **_observe(first))
260
+ results = scenario.substitute(case.check["results"], variables)
261
+ follow = scenario.substitute(case.check["followup"], variables)
262
+ second_turn = dict(turn, messages=_tool_turn(turn, first.norm, results, variables),
263
+ tool_choice=follow.get("tool_choice"), max_tokens=follow.get("max_tokens", turn.get("max_tokens")))
264
+ second_turn.pop("parallel_tool_calls", None)
265
+ second = call(ctx, case.protocol, second_turn, variables)
266
+ second.event["tool"] = False
267
+ category, details = judge(ctx, second, follow["expect"], variables)
268
+ observed = _observe(second)
269
+ observed["tool_calls_first_turn"] = len(first.norm.tool_calls)
270
+ return Verdict(category, ["step 2: " + d for d in details], **observed)
271
+
272
+
273
+ def _handle(handler, args, files):
274
+ path = args.get("path", "")
275
+ if handler["op"] == "read":
276
+ return files.get(path, "error: no such file")
277
+ if handler["op"] == "write":
278
+ files[path] = args.get("content", "")
279
+ return "ok: wrote %d bytes" % len(files[path])
280
+ if handler["op"] == "run":
281
+ return handler["pass"] if handler["needle"] in files.get(handler["path"], "") else handler["fail"]
282
+ return "error: unsupported handler"
283
+
284
+
285
+ def run_agent(ctx, case, variables):
286
+ check = scenario.substitute(copy.deepcopy(case.check), variables)
287
+ files = dict(check["files"])
288
+ messages = [{"role": "user", "content": check["task"]}]
289
+ names = []
290
+ for index, step in enumerate(check["steps"]):
291
+ turn = {"system": check["system"], "messages": list(messages), "tools": check["tools"],
292
+ "tool_choice": {"name": step["force"]}, "max_tokens": check["max_tokens"]}
293
+ out = call(ctx, case.protocol, turn, variables)
294
+ expect = {"finish_reason": ["tool_calls", "stop"],
295
+ "tool_calls": {"min": 1, "max": 1, "names": [step["force"]], "unique_ids": True,
296
+ "arguments_schema": "from_tools", "arguments": step.get("arguments", {})}}
297
+ category, details = judge(ctx, out, expect, variables, check["tools"])
298
+ if category:
299
+ return Verdict(category, ["step %d (%s): %s" % (index + 1, step["force"], d) for d in details], tool_sequence=names)
300
+ call_ = out.norm.tool_calls[0]
301
+ names.append(call_["name"])
302
+ result = _handle(check["handlers"][call_["name"]], call_["args"], files)
303
+ messages += [
304
+ {"role": "assistant", "content": out.norm.content or None,
305
+ "tool_calls": [{"id": call_["id"], "name": call_["name"], "arguments": call_["arguments"] or "{}"}]},
306
+ {"role": "tool", "tool_call_id": call_["id"], "content": result},
307
+ ]
308
+ final = check["final"]
309
+ out = call(ctx, case.protocol, {"system": check["system"], "messages": messages, "tools": check["tools"],
310
+ "tool_choice": final["tool_choice"], "max_tokens": final["max_tokens"]}, variables)
311
+ out.event["tool"] = False
312
+ category, details = judge(ctx, out, final["expect"], variables)
313
+ return Verdict(category, ["final: " + d for d in details], tool_sequence=names, **_observe(out))
314
+
315
+
316
+ def run_cancel_recover(ctx, case, variables):
317
+ turn = _turn(case, ctx, variables)
318
+ wanted = case.check["cancel_after_frames"]
319
+ out = call(ctx, case.protocol, turn, variables, stop_after=wanted)
320
+ if out.failed:
321
+ return Verdict(out.category, [out.detail])
322
+ if not out.stream_result.cancelled:
323
+ return Verdict("assertion", ["stream ended before %d events, so cancellation was not exercised" % wanted])
324
+ cancelled_ms = round(out.elapsed * 1000)
325
+ recover = scenario.substitute(case.check["recover"], variables)
326
+ again = call(ctx, case.protocol, recover, variables)
327
+ category, details = judge(ctx, again, case.check["expect"], variables)
328
+ verdict = Verdict(category, ["recovery request: " + d for d in details], cancelled_after_ms=cancelled_ms,
329
+ recovery_ms=round(again.elapsed * 1000))
330
+ return verdict
331
+
332
+
333
+ def run_concurrent(ctx, case, variables):
334
+ count = case.check["parallel"]
335
+ runs = [ctx.variables() for _ in range(count)]
336
+
337
+ def one(variables_):
338
+ turn = _turn(case, ctx, variables_)
339
+ out = call(ctx, case.protocol, turn, variables_)
340
+ return judge(ctx, out, case.check["expect"], variables_, turn.get("tools", []))
341
+
342
+ with concurrent.futures.ThreadPoolExecutor(max_workers=count) as pool:
343
+ results = list(pool.map(one, runs))
344
+ failures = [(i, r) for i, r in enumerate(results) if r[0]]
345
+ if failures:
346
+ first = failures[0][1]
347
+ return Verdict(first[0], ["request %d of %d: %s" % (failures[0][0] + 1, count, d) for d in first[1]],
348
+ parallel=count, failed=len(failures))
349
+ return Verdict(None, [], parallel=count, failed=0)
350
+
351
+
352
+ def run_error_probe(ctx, case, variables):
353
+ check = case.check
354
+ client = ctx.client
355
+ if "key" in check:
356
+ client = Client(ctx.client.base_url, check["key"], ctx.redact, ctx.client.timeout, ctx.client.on_request)
357
+ path = ctx.config.raw["protocols"][case.protocol]["path"]
358
+ if "raw_body" in check:
359
+ body = check["raw_body"].encode()
360
+ elif "body" in check:
361
+ body = check["body"]
362
+ else:
363
+ model_id = check.get("model", ctx.model_id)
364
+ body = scenario.build(case.protocol, model_id, scenario.substitute(check["turn"], variables), ctx.config.raw, {})
365
+ event = {"protocol": case.protocol, "stream": False, "tool": False, "ok": False, "category": None,
366
+ "completed": False, "cancelled": False, "status": 0, "ttft": None, "total": None, "probe": True}
367
+ ctx.events.append(event)
368
+ response = client.request("POST", path, body, timeout=ctx.timeouts["request_s"], kind="probe")
369
+ event.update(status=response.status, total=response.elapsed)
370
+ ctx.traces.append({"protocol": case.protocol, "method": "POST", "path": path, "stream": False,
371
+ "request": {"probe": case.check["id"], "body": "raw" if "raw_body" in check else "json"},
372
+ "http_status": response.status, "total_ms": round(response.elapsed * 1000),
373
+ "cf_ray": response.headers.get("cf-ray"), "request_id": response.headers.get("x-request-id"),
374
+ "error": _envelope_detail(response, ctx)})
375
+ wanted = check["expect"]["status"]
376
+ if response.status not in wanted:
377
+ category = "assertion" if response.status < 500 else "upstream"
378
+ event["category"] = category
379
+ return Verdict(category, ["HTTP %d, expected one of %s" % (response.status, wanted)], http_status=response.status)
380
+ if check["expect"].get("error_envelope"):
381
+ problems = protocol.error_violations(response.json())
382
+ if problems:
383
+ event["category"] = "protocol"
384
+ return Verdict("protocol", problems, http_status=response.status)
385
+ event["ok"] = True
386
+ return Verdict(None, [], http_status=response.status)
387
+
388
+
389
+ RUNNERS = {
390
+ "request": run_request,
391
+ "roundtrip": run_roundtrip,
392
+ "agent": run_agent,
393
+ "cancel_recover": run_cancel_recover,
394
+ "concurrent": run_concurrent,
395
+ "error_probe": run_error_probe,
396
+ }
397
+
398
+
399
+ def execute(ctx, case):
400
+ """Run one case and return a result dict; never raises."""
401
+ started = time.monotonic()
402
+ result = {
403
+ "id": case.id, "capability": case.capability, "protocol": case.protocol, "severity": case.severity,
404
+ "title": case.check["title"], "endpoint": ctx.config.raw["protocols"][case.protocol]["path"],
405
+ "status": "pass", "category": None, "details": [], "evidence": "live",
406
+ }
407
+ marker, trace_marker = len(ctx.events), len(ctx.traces)
408
+ try:
409
+ verdict = RUNNERS[case.kind](ctx, case, ctx.variables())
410
+ verdict.observations.pop("_out", None)
411
+ result["observations"] = verdict.observations
412
+ if not verdict.passed:
413
+ result["status"], result["category"], result["details"] = "fail", verdict.category, verdict.details
414
+ except BudgetExceeded as error:
415
+ result.update(status="untested", category="budget", details=[str(error)])
416
+ except TransportError as error:
417
+ result.update(status="fail", category=error.category, details=[error.detail])
418
+ except Exception as error: # a harness defect must not hide other models' results
419
+ result.update(status="fail", category="harness", details=[ctx.redact(type(error).__name__ + ": " + str(error))])
420
+ result["duration_ms"] = round((time.monotonic() - started) * 1000)
421
+ if result["status"] != "pass":
422
+ result["trace"] = ctx.traces[trace_marker:]
423
+ mine = ctx.events[marker:]
424
+ result["requests"] = len(mine)
425
+ streams = [e["ttft"] for e in mine if e["stream"] and e["ttft"] is not None]
426
+ result["ttft_ms"] = round(streams[0] * 1000) if streams else None
427
+ if result["status"] == "fail" and result["category"] in INFRA_CATEGORIES:
428
+ result["infrastructure"] = True
429
+ return result
@@ -0,0 +1,117 @@
1
+ """Command-line entry for `infra-cliproxy model-health` and `model-suite`."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import sys
8
+
9
+ from . import config as configuration
10
+ from . import findings
11
+ from . import report as reporting
12
+ from . import runner, simulate
13
+ from .emulator import Emulator
14
+
15
+ SCRIPTS = pathlib.Path(__file__).resolve().parents[1]
16
+ EXIT = {"ok": 0, "failing": 1, "incomplete": 2, "usage": 3}
17
+
18
+
19
+ def resolve_key(config, offline):
20
+ """Client key from the environment, an existing Vault login, or the installed host. Never printed."""
21
+ if offline:
22
+ return None
23
+ value = os.environ.get(config.raw["endpoint"]["key_env"], "").strip()
24
+ if value:
25
+ return value
26
+ sys.path.insert(0, str(SCRIPTS))
27
+ token = os.environ.get("VAULT_TOKEN", "").strip()
28
+ if token:
29
+ import cloud_state
30
+
31
+ try:
32
+ return cloud_state.client_key(token)
33
+ except Exception:
34
+ return None
35
+ import cliproxy_host
36
+
37
+ root = cliproxy_host.default_root()
38
+ try:
39
+ return (root / "state/secrets/client-key").read_text().strip() or None
40
+ except OSError:
41
+ return None
42
+
43
+
44
+ def parser(default_profile):
45
+ p = argparse.ArgumentParser(prog="infra-cliproxy model-suite" if default_profile == "nightly" else "infra-cliproxy model-health",
46
+ description=__doc__)
47
+ p.add_argument("--profile", default=default_profile, choices=["liveness", "readiness", "light", "nightly", "manual"])
48
+ p.add_argument("--suite", default="all", choices=["core", "cursor", "all"])
49
+ p.add_argument("--target", default="live", choices=["live", "mock"],
50
+ help="mock runs the same declarative suite against the local emulator (no credentials, no spend)")
51
+ p.add_argument("--base-url", help="Override the endpoint base URL (HTTPS required for live targets)")
52
+ p.add_argument("--model", action="append", dest="models", metavar="MODEL_ID", help="Limit to a configured model id (repeatable)")
53
+ p.add_argument("--config", help="Alternate models.json")
54
+ p.add_argument("--output", metavar="DIR", help="Write model-health.json and model-health.md here")
55
+ p.add_argument("--state", metavar="FILE", help="Persist last-success times here")
56
+ p.add_argument("--check", action="append", dest="only", metavar="CAPABILITY",
57
+ help="Run only matching capabilities, for example chat.tools or 'responses.*' (repeatable); cheap reproduction")
58
+ p.add_argument("--force-probe", action="store_true",
59
+ help="Probe models even if they were rate limited recently (normally paused to protect quota)")
60
+ p.add_argument("--json", action="store_true", help="Print the JSON report instead of the human report")
61
+ p.add_argument("--seed", type=int, help="Seed fixture tokens for reproducible requests")
62
+ p.add_argument("--max-requests", type=int, help="Override the profile's total request budget")
63
+ p.add_argument("--concurrency", type=int, help="Override the profile's model concurrency")
64
+ p.add_argument("--timeout-scale", type=float, help="Multiply every timeout")
65
+ p.add_argument("--fail-on", default="unhealthy", choices=["unhealthy", "degraded"])
66
+ p.add_argument("--allow-unknown", action="store_true", help="Exit 0 when the result is unknown (for example no credentials)")
67
+ return p
68
+
69
+
70
+ def write_reports(directory, report, text):
71
+ directory = pathlib.Path(directory)
72
+ directory.mkdir(parents=True, exist_ok=True)
73
+ (directory / "model-health.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
74
+ (directory / "model-health.md").write_text(text + "\n")
75
+ (directory / "agent-brief.md").write_text(findings.render_brief(report) + "\n")
76
+
77
+
78
+ def exit_code(status, fail_on, allow_unknown):
79
+ if status == "unhealthy" or (status == "degraded" and fail_on == "degraded"):
80
+ return EXIT["failing"]
81
+ if status == "unknown" and not allow_unknown:
82
+ return EXIT["incomplete"]
83
+ return EXIT["ok"]
84
+
85
+
86
+ def main(argv, default_profile="light"):
87
+ args = parser(default_profile).parse_args(argv)
88
+ try:
89
+ config = configuration.load(args.config)
90
+ except (configuration.ConfigError, OSError, ValueError) as error:
91
+ print(error, file=sys.stderr)
92
+ return EXIT["usage"]
93
+ emulator = None
94
+ base_url = args.base_url
95
+ try:
96
+ if args.target == "mock":
97
+ emulator = Emulator([m["id"] for m in config.models])
98
+ base_url = emulator.start()
99
+ key = emulator.key
100
+ else:
101
+ key = resolve_key(config, offline=args.profile == "liveness")
102
+ report = runner.run(
103
+ config, args.profile, args.suite, base_url, key, args.target, args.models, args.seed, args.state,
104
+ {"max_requests": args.max_requests, "concurrency": args.concurrency, "timeout_scale": args.timeout_scale},
105
+ simulate=lambda: simulate.run_all(config, args.seed or 0), only=args.only, force_probe=args.force_probe,
106
+ )
107
+ except ValueError as error:
108
+ print(error, file=sys.stderr)
109
+ return EXIT["usage"]
110
+ finally:
111
+ if emulator:
112
+ emulator.stop()
113
+ text = reporting.render(report, config)
114
+ if args.output:
115
+ write_reports(args.output, report, text)
116
+ print(json.dumps(report, indent=2, sort_keys=True) if args.json else text)
117
+ return exit_code(report["summary"]["status"], args.fail_on, args.allow_unknown)