@crvouga/dev-fleet 0.0.0-stage → 0.2.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 (363) 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 +357 -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 +158 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +9 -0
  33. package/ansible/monitoring/README.md +215 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +172 -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/hub-route-darwin.yml +51 -0
  40. package/ansible/monitoring/lock.json +16 -0
  41. package/ansible/net/remote-probe.sh +13 -0
  42. package/ansible/pyproject.toml +12 -0
  43. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  44. package/ansible/ubuntu/baseline.yml +89 -0
  45. package/ansible/ubuntu/disk-gc.py +336 -0
  46. package/ansible/ubuntu/disk-guard.py +104 -0
  47. package/ansible/ubuntu/policy.json +52 -0
  48. package/ansible/ubuntu/recovery.yml +15 -0
  49. package/ansible/ubuntu/t3/policy.json +7 -0
  50. package/ansible/ubuntu/t3-desktop.yml +220 -0
  51. package/ansible/ubuntu/t3-health.sh +33 -0
  52. package/ansible/ubuntu/t3-policy.yml +76 -0
  53. package/ansible/ubuntu/t3-route.yml +28 -0
  54. package/ansible/ubuntu/tailscale.yml +78 -0
  55. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  56. package/ansible/ubuntu/tasks/docker.yml +31 -0
  57. package/ansible/ubuntu/tasks/packages.yml +56 -0
  58. package/ansible/ubuntu/tasks/power.yml +51 -0
  59. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  60. package/ansible/ubuntu/tasks/resources.yml +312 -0
  61. package/ansible/ubuntu/tasks/services.yml +31 -0
  62. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  63. package/ansible/ubuntu/tasks/user.yml +120 -0
  64. package/ansible/uv.lock +190 -0
  65. package/bin/devfleet.mjs +36 -0
  66. package/bootstrap/bootstrap.ps1 +50 -0
  67. package/bootstrap/bootstrap.sh +80 -0
  68. package/bootstrap/devfleet +7 -0
  69. package/bootstrap/docker-desktop.sh +33 -0
  70. package/bootstrap/doctor.ts +234 -0
  71. package/bootstrap/fleet-manager-run.sh +103 -0
  72. package/bootstrap/fleet-remote-bootstrap.sh +258 -0
  73. package/bootstrap/global-config.ts +171 -0
  74. package/bootstrap/install-launcher.sh +10 -0
  75. package/bootstrap/native-aws.ts +86 -0
  76. package/bootstrap/native-t3.ts +154 -0
  77. package/bootstrap/npm.ts +358 -0
  78. package/bootstrap/release-version.ts +157 -0
  79. package/bootstrap/setup-status.ts +51 -0
  80. package/bootstrap/setup.ts +88 -0
  81. package/bootstrap/shell-path.ts +105 -0
  82. package/bootstrap/verify-t3-archive.py +49 -0
  83. package/bootstrap/worker-auth.ts +65 -0
  84. package/bootstrap/worker-github.ts +47 -0
  85. package/bootstrap/worker-tools.ts +24 -0
  86. package/cliproxy/AGENTS.md +11 -0
  87. package/cliproxy/README.md +269 -0
  88. package/cliproxy/clients/bootstrap.py +143 -0
  89. package/cliproxy/clients/launcher.py +57 -0
  90. package/cliproxy/compose.yaml +90 -0
  91. package/cliproxy/config.template.yaml +70 -0
  92. package/cliproxy/docs/cloud-adoption.md +16 -0
  93. package/cliproxy/docs/deployment.png +0 -0
  94. package/cliproxy/docs/deployment.svg +109 -0
  95. package/cliproxy/docs/health-workflow.example.yml +185 -0
  96. package/cliproxy/docs/migration.md +131 -0
  97. package/cliproxy/docs/model-health-report.schema.json +364 -0
  98. package/cliproxy/docs/model-health.md +393 -0
  99. package/cliproxy/image.lock.json +5 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  101. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  102. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  103. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  104. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  105. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  106. package/cliproxy/models.json +1618 -0
  107. package/cliproxy/requirements.lock +78 -0
  108. package/cliproxy/scripts/accounts.py +38 -0
  109. package/cliproxy/scripts/audit.py +123 -0
  110. package/cliproxy/scripts/backup +234 -0
  111. package/cliproxy/scripts/check.py +34 -0
  112. package/cliproxy/scripts/checkpoint.py +226 -0
  113. package/cliproxy/scripts/cli.py +64 -0
  114. package/cliproxy/scripts/cliproxy_host.py +257 -0
  115. package/cliproxy/scripts/cloud_state.py +150 -0
  116. package/cliproxy/scripts/container-health +6 -0
  117. package/cliproxy/scripts/docker +24 -0
  118. package/cliproxy/scripts/external-health.py +79 -0
  119. package/cliproxy/scripts/fleet_config.py +43 -0
  120. package/cliproxy/scripts/fleet_sync.py +274 -0
  121. package/cliproxy/scripts/initialize +84 -0
  122. package/cliproxy/scripts/install-dependencies +48 -0
  123. package/cliproxy/scripts/install.py +406 -0
  124. package/cliproxy/scripts/model_health.py +16 -0
  125. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  126. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  127. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  128. package/cliproxy/scripts/modelhealth/config.py +186 -0
  129. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  130. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  131. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  132. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  133. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  134. package/cliproxy/scripts/modelhealth/report.py +150 -0
  135. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  136. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  137. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  138. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  139. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  140. package/cliproxy/scripts/monitor +68 -0
  141. package/cliproxy/scripts/ops +527 -0
  142. package/cliproxy/scripts/public-endpoint +127 -0
  143. package/cliproxy/scripts/recover_node.py +177 -0
  144. package/cliproxy/scripts/restore-verify +60 -0
  145. package/cliproxy/scripts/retention.py +144 -0
  146. package/cliproxy/scripts/service-start +7 -0
  147. package/cliproxy/scripts/validate-config +56 -0
  148. package/cliproxy/scripts/vault-access.py +57 -0
  149. package/cliproxy/scripts/vault-backup-login +55 -0
  150. package/cliproxy/scripts/vault-recovery +56 -0
  151. package/cliproxy/scripts/vault-renew +30 -0
  152. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  153. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  155. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  156. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  157. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  158. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  159. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  160. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  161. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  162. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  163. package/cliproxy/vendor/LICENSE +22 -0
  164. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  165. package/docs/consumer-setup.md +408 -0
  166. package/docs/declared.md +89 -0
  167. package/docs/machine-setup.md +399 -0
  168. package/docs/operations.md +276 -0
  169. package/docs/releasing.md +103 -0
  170. package/docs/state.md +254 -0
  171. package/docs/topology.md +67 -0
  172. package/docs/ubuntu-sync.md +351 -0
  173. package/docs/worker-contract.md +67 -0
  174. package/examples/fleet.json +100 -0
  175. package/examples/roster.json +13 -0
  176. package/llms.txt +35 -0
  177. package/mise.lock +1521 -0
  178. package/mise.toml +110 -0
  179. package/package.json +74 -4
  180. package/schema/fleet.schema.json +420 -0
  181. package/schema/roster.schema.json +77 -0
  182. package/src/agents/account-relay.ts +111 -0
  183. package/src/agents/accounts.ts +85 -0
  184. package/src/agents/audit.ts +77 -0
  185. package/src/agents/cli.ts +13 -0
  186. package/src/agents/command.ts +204 -0
  187. package/src/agents/converge.ts +123 -0
  188. package/src/agents/doctor.ts +107 -0
  189. package/src/agents/errors.ts +2 -0
  190. package/src/agents/files.ts +73 -0
  191. package/src/agents/fleet-verify.ts +165 -0
  192. package/src/agents/launch.ts +290 -0
  193. package/src/agents/native-verify.ts +371 -0
  194. package/src/agents/provider-errors.ts +57 -0
  195. package/src/agents/registry.ts +61 -0
  196. package/src/agents/render.ts +110 -0
  197. package/src/agents/round-trip.ts +192 -0
  198. package/src/agents/secrets.ts +13 -0
  199. package/src/agents/toolchain.ts +34 -0
  200. package/src/agents/verify-cache.ts +103 -0
  201. package/src/agents/verify.ts +127 -0
  202. package/src/auth-checks.ts +163 -0
  203. package/src/cli/agent-guide.ts +129 -0
  204. package/src/cli/app.ts +122 -0
  205. package/src/cli/cli.ts +28 -0
  206. package/src/cli/command-index.ts +57 -0
  207. package/src/cli/contracts.ts +25 -0
  208. package/src/cli/fleet-reference.ts +17 -0
  209. package/src/cli/fleet-state.ts +499 -0
  210. package/src/cli/fleet-sync-checkout.ts +130 -0
  211. package/src/cli/fleet-sync-enrollment.ts +140 -0
  212. package/src/cli/fleet-sync-forward.ts +134 -0
  213. package/src/cli/fleet-sync-manager.ts +249 -0
  214. package/src/cli/fleet-sync-monitoring.ts +185 -0
  215. package/src/cli/fleet-sync-output.ts +317 -0
  216. package/src/cli/fleet-sync-privilege.ts +27 -0
  217. package/src/cli/fleet-sync-progress.ts +109 -0
  218. package/src/cli/fleet-sync-remote.ts +242 -0
  219. package/src/cli/fleet-sync-report.ts +26 -0
  220. package/src/cli/fleet-sync-schedule.ts +138 -0
  221. package/src/cli/fleet-sync-state.ts +86 -0
  222. package/src/cli/fleet-sync-vault.ts +166 -0
  223. package/src/cli/fleet-sync.ts +375 -0
  224. package/src/cli/fleet.ts +165 -0
  225. package/src/cli/managed-health.ts +219 -0
  226. package/src/cli/node-deployment.ts +34 -0
  227. package/src/cli/node-sync.ts +121 -0
  228. package/src/cli/node-t3.ts +31 -0
  229. package/src/cli/options.ts +141 -0
  230. package/src/cli/probe-source.ts +7 -0
  231. package/src/cli/program.ts +185 -0
  232. package/src/cli/project.ts +51 -0
  233. package/src/cli/register-fleet.ts +296 -0
  234. package/src/cli/registry.ts +185 -0
  235. package/src/cli/repair-apply.ts +67 -0
  236. package/src/cli/repair-guard.ts +11 -0
  237. package/src/cli/repair-inspect.ts +150 -0
  238. package/src/cli/repair-script.ts +76 -0
  239. package/src/cli/repair.ts +209 -0
  240. package/src/cli/roster.ts +105 -0
  241. package/src/cli/search.ts +35 -0
  242. package/src/cli/settings.ts +67 -0
  243. package/src/cli/source-operations.ts +102 -0
  244. package/src/cli/t3/command.ts +90 -0
  245. package/src/cli/t3/register.ts +55 -0
  246. package/src/cli/tailnet.ts +127 -0
  247. package/src/cli/ui-actions.ts +133 -0
  248. package/src/cli/ui-controller.ts +113 -0
  249. package/src/cli/ui-data.ts +106 -0
  250. package/src/cli/ui-execute.ts +163 -0
  251. package/src/cli/ui-repairs.ts +34 -0
  252. package/src/cli/ui-reports.ts +211 -0
  253. package/src/cli/ui-targets.ts +100 -0
  254. package/src/cli/ui-terminal.ts +109 -0
  255. package/src/cli/ui-types.ts +49 -0
  256. package/src/cli/ui-wizard.ts +101 -0
  257. package/src/cli/ui.ts +9 -0
  258. package/src/cli/worker-health-output.ts +37 -0
  259. package/src/config.ts +186 -0
  260. package/src/configure.ts +255 -0
  261. package/src/definition.ts +290 -0
  262. package/src/diagnostics.ts +92 -0
  263. package/src/doctor-check.ts +10 -0
  264. package/src/errors.ts +1 -0
  265. package/src/fingerprint.ts +120 -0
  266. package/src/fleet-config.ts +174 -0
  267. package/src/input-hashes.ts +73 -0
  268. package/src/machine/local-source.ts +52 -0
  269. package/src/machine/lock-error.ts +2 -0
  270. package/src/machine/platform.ts +44 -0
  271. package/src/machine/source.ts +68 -0
  272. package/src/machine/state.ts +173 -0
  273. package/src/machine-health.ts +95 -0
  274. package/src/managed-source.ts +47 -0
  275. package/src/manager-failure.ts +48 -0
  276. package/src/manager-sync.ts +405 -0
  277. package/src/manager.ts +62 -0
  278. package/src/monitoring/agent-evidence.ts +146 -0
  279. package/src/monitoring/apply.ts +183 -0
  280. package/src/monitoring/beszel.ts +198 -0
  281. package/src/monitoring/enroll.ts +111 -0
  282. package/src/monitoring/hub-access.ts +114 -0
  283. package/src/monitoring/hub-backup.ts +121 -0
  284. package/src/monitoring/hub-viewer.ts +142 -0
  285. package/src/monitoring/hub.ts +93 -0
  286. package/src/monitoring/manager-agent.ts +73 -0
  287. package/src/monitoring/manager-hub.ts +248 -0
  288. package/src/monitoring/remote.ts +65 -0
  289. package/src/network-definition.ts +83 -0
  290. package/src/node-converge-linux.ts +374 -0
  291. package/src/node-converge-shared.ts +83 -0
  292. package/src/node-converge.ts +40 -0
  293. package/src/onboard.ts +35 -0
  294. package/src/package-info.ts +45 -0
  295. package/src/platform/ansible-receipt.ts +128 -0
  296. package/src/platform/ansible.ts +66 -0
  297. package/src/platform/darwin.ts +203 -0
  298. package/src/platform/detect.ts +11 -0
  299. package/src/platform/fallback.ts +110 -0
  300. package/src/platform/index.ts +38 -0
  301. package/src/platform/launchd.ts +98 -0
  302. package/src/platform/linux.ts +139 -0
  303. package/src/platform/ps.ts +45 -0
  304. package/src/platform/resource-verify.ts +132 -0
  305. package/src/platform/systemd-user.ts +95 -0
  306. package/src/platform/t3-policy.ts +137 -0
  307. package/src/platform/t3-route.ts +68 -0
  308. package/src/platform/t3-service.ts +75 -0
  309. package/src/platform/types.ts +123 -0
  310. package/src/platform/ubuntu-audit.ts +66 -0
  311. package/src/platform/ubuntu-verify.ts +105 -0
  312. package/src/platform/windows.ts +148 -0
  313. package/src/power-recovery.ts +55 -0
  314. package/src/probe.ts +51 -0
  315. package/src/protocol.ts +119 -0
  316. package/src/proxy-converge.ts +85 -0
  317. package/src/remote-bootstrap.ts +210 -0
  318. package/src/remote-entry.ts +89 -0
  319. package/src/remote-lock.ts +12 -0
  320. package/src/repair-catalog.ts +206 -0
  321. package/src/repair-plan.ts +78 -0
  322. package/src/repair-runner.ts +84 -0
  323. package/src/report.ts +71 -0
  324. package/src/runner.ts +104 -0
  325. package/src/secrets.ts +169 -0
  326. package/src/service-auth.ts +115 -0
  327. package/src/ssh.ts +135 -0
  328. package/src/state/bucket.ts +208 -0
  329. package/src/state/check.ts +74 -0
  330. package/src/state/connection.ts +92 -0
  331. package/src/state/enrollment.ts +45 -0
  332. package/src/state/legacy.ts +89 -0
  333. package/src/state/litestream.ts +143 -0
  334. package/src/state/operator.ts +20 -0
  335. package/src/state/paths.ts +40 -0
  336. package/src/state/roster.ts +62 -0
  337. package/src/state/store.ts +169 -0
  338. package/src/state/sync.ts +352 -0
  339. package/src/sync.ts +104 -0
  340. package/src/t3/accounts.ts +81 -0
  341. package/src/t3/desired.ts +165 -0
  342. package/src/t3/files.ts +81 -0
  343. package/src/t3/names-check.ts +53 -0
  344. package/src/t3/names.ts +155 -0
  345. package/src/t3/plan.ts +82 -0
  346. package/src/t3/providers.ts +74 -0
  347. package/src/t3/reconcile.ts +123 -0
  348. package/src/t3-endpoint.ts +104 -0
  349. package/src/t3-linked-check.ts +123 -0
  350. package/src/tailnet-health.ts +231 -0
  351. package/src/tailnet-matching.ts +37 -0
  352. package/src/tailscale.ts +141 -0
  353. package/src/toolchain-checks.ts +108 -0
  354. package/src/topology.ts +24 -0
  355. package/src/transport.ts +122 -0
  356. package/src/types.ts +99 -0
  357. package/src/vault-config.ts +38 -0
  358. package/src/worker-access-checks.ts +168 -0
  359. package/src/worker-capacity.ts +34 -0
  360. package/src/worker-host-checks.ts +181 -0
  361. package/src/worker-policy.ts +64 -0
  362. package/src/worker-readiness.ts +64 -0
  363. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,536 @@
1
+ """Protocol-faithful local emulator of the proxy's OpenAI surface.
2
+
3
+ It is deterministic and credential-free. It implements Chat Completions and
4
+ Responses (streaming and not), tool calls, structured output, OpenAI error
5
+ envelopes, and the proxy's multi-account routing policy (fill-first or round
6
+ robin, session affinity, cooldown after quota or transient failures, bounded
7
+ credential retries), with a
8
+ virtual clock and injectable faults. It is a test double for the harness and
9
+ its contracts; it makes no claim about real provider or Cursor behavior.
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import threading
16
+ import time
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+
19
+ TOKEN = re.compile(r"[A-Z]{3}-[0-9]{4}")
20
+
21
+
22
+ def _words(text):
23
+ return len(str(text).split())
24
+
25
+
26
+ class Account:
27
+ def __init__(self, ident, models=None, quota=None):
28
+ self.id = ident
29
+ self.models = set(models) if models is not None else None
30
+ self.quota = quota # remaining requests; None is unlimited, 0 is exhausted
31
+ self.fail_next = 0 # transient upstream failures still to emit
32
+ self.cooldown_until = 0.0
33
+ self.served = 0
34
+
35
+ def serves(self, model_id):
36
+ return self.models is None or model_id in self.models
37
+
38
+
39
+ class Emulator:
40
+ def __init__(self, model_ids, keys=("emulator-client-key-0000000000000000",), accounts=None,
41
+ cooldown_s=30.0, transient_cooldown_s=60.0, max_retry_credentials=0, chunk_delay_s=0.0,
42
+ strategy="fill-first"):
43
+ self.model_ids = list(model_ids)
44
+ self.keys = set(keys)
45
+ self.open_api = False # fault: accepts anonymous callers
46
+ self.accounts = accounts or [Account("acct-a"), Account("acct-b")]
47
+ self.cooldown_s = cooldown_s
48
+ self.transient_cooldown_s = transient_cooldown_s # upstream's documented 60s transient-error cooldown
49
+ self.strategy = strategy
50
+ self.max_retry_credentials = max_retry_credentials
51
+ self.chunk_delay_s = chunk_delay_s
52
+ self.faults = {}
53
+ self.offset = 0.0
54
+ self.lock = threading.Lock()
55
+ self.pointer = {}
56
+ self.affinity = {}
57
+ self.routing_log = []
58
+ self.requests = 0
59
+ self.server = None
60
+
61
+ # ---------------------------------------------------------------- control
62
+ def now(self):
63
+ return time.monotonic() + self.offset
64
+
65
+ def advance(self, seconds):
66
+ with self.lock:
67
+ self.offset += seconds
68
+
69
+ def fault(self, **faults):
70
+ with self.lock:
71
+ self.faults.update(faults)
72
+
73
+ def clear_faults(self):
74
+ with self.lock:
75
+ self.faults = {}
76
+
77
+ def account(self, ident):
78
+ return next(a for a in self.accounts if a.id == ident)
79
+
80
+ def start(self):
81
+ emulator = self
82
+
83
+ class Handler(_Handler):
84
+ owner = emulator
85
+
86
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
87
+ self.server.daemon_threads = True
88
+ threading.Thread(target=self.server.serve_forever, args=(0.02,), daemon=True).start()
89
+ return "http://127.0.0.1:%d/v1" % self.server.server_address[1]
90
+
91
+ def stop(self):
92
+ if self.server:
93
+ self.server.shutdown()
94
+ self.server.server_close()
95
+
96
+ @property
97
+ def key(self):
98
+ return sorted(self.keys)[0]
99
+
100
+ # ---------------------------------------------------------------- routing
101
+ def route(self, model_id, affinity):
102
+ """Return (account, error) following the documented routing policy.
103
+
104
+ fill-first: accounts are tried in declaration order, so one account takes all traffic until
105
+ it is exhausted (quota) or failing (transient), then the next. round-robin rotates instead.
106
+ A session bound to an available account stays on it (an established binding outranks order);
107
+ when that account is unavailable the session is rebound to the failover account.
108
+ max_retry_credentials of 0 means every eligible credential may be tried.
109
+ """
110
+ with self.lock:
111
+ now = self.now()
112
+ candidates = [a for a in self.accounts if a.serves(model_id)]
113
+ tried = []
114
+ if not candidates:
115
+ self.routing_log.append({"model": model_id, "tried": [], "served_by": None})
116
+ return None, (404, "model_not_found", "No account serves this model.", None)
117
+ if self.strategy == "round-robin":
118
+ start = self.pointer.get(model_id, 0)
119
+ order = [candidates[(start + i) % len(candidates)] for i in range(len(candidates))]
120
+ else:
121
+ order = list(candidates)
122
+ sticky = self.affinity.get((model_id, affinity))
123
+ if sticky is not None:
124
+ order = [a for a in order if a.id == sticky] + [a for a in order if a.id != sticky]
125
+ last, attempts = None, 0
126
+ for account in order:
127
+ if account.cooldown_until > now:
128
+ tried.append((account.id, "cooldown"))
129
+ continue
130
+ if self.max_retry_credentials and attempts >= self.max_retry_credentials:
131
+ break
132
+ attempts += 1
133
+ if account.quota is not None and account.quota <= 0:
134
+ account.cooldown_until = now + self.cooldown_s
135
+ tried.append((account.id, "quota"))
136
+ last = (429, "rate_limit_exceeded", "Account quota exhausted.", self.cooldown_s)
137
+ continue
138
+ if account.fail_next > 0:
139
+ account.fail_next -= 1
140
+ account.cooldown_until = now + self.transient_cooldown_s
141
+ tried.append((account.id, "upstream_error"))
142
+ last = (503, "upstream_unavailable", "Upstream provider is temporarily unavailable.", None)
143
+ continue
144
+ if account.quota is not None:
145
+ account.quota -= 1
146
+ account.served += 1
147
+ self.pointer[model_id] = (candidates.index(account) + 1) % len(candidates)
148
+ self.affinity[(model_id, affinity)] = account.id
149
+ tried.append((account.id, "served"))
150
+ self.routing_log.append({"model": model_id, "tried": tried, "served_by": account.id})
151
+ return account, None
152
+ if last is None:
153
+ waits = [a.cooldown_until - now for a in candidates if a.cooldown_until > now]
154
+ last = (429, "rate_limit_exceeded", "All credentials are cooling down.", max(waits or [1.0]))
155
+ self.routing_log.append({"model": model_id, "tried": tried, "served_by": None})
156
+ return None, last
157
+
158
+
159
+ # --------------------------------------------------------------------- brain
160
+
161
+ def _text_of(content):
162
+ if isinstance(content, str):
163
+ return content
164
+ if isinstance(content, list):
165
+ return " ".join(str(p.get("text", "")) for p in content if isinstance(p, dict))
166
+ return ""
167
+
168
+
169
+ def _conversation(protocol, body):
170
+ """Flatten either protocol into [(role, text)], tool results and tool defs."""
171
+ turns, results, system = [], [], []
172
+ if protocol == "chat":
173
+ for message in body.get("messages") or []:
174
+ role, text = message.get("role"), _text_of(message.get("content"))
175
+ if role in ("system", "developer"):
176
+ system.append(text)
177
+ elif role == "tool":
178
+ results.append(text)
179
+ turns.append(("tool", text))
180
+ else:
181
+ turns.append((role, text))
182
+ tools = [t["function"] for t in body.get("tools") or []]
183
+ else:
184
+ if body.get("instructions"):
185
+ system.append(body["instructions"])
186
+ items = body.get("input")
187
+ items = [{"role": "user", "content": items}] if isinstance(items, str) else (items or [])
188
+ for item in items:
189
+ if item.get("type") == "function_call_output":
190
+ results.append(str(item.get("output")))
191
+ turns.append(("tool", str(item.get("output"))))
192
+ elif item.get("type") == "function_call":
193
+ continue
194
+ elif item.get("role") in ("system", "developer"):
195
+ system.append(_text_of(item.get("content")))
196
+ else:
197
+ turns.append((item.get("role"), _text_of(item.get("content"))))
198
+ tools = [t for t in body.get("tools") or []]
199
+ return turns, results, system, tools
200
+
201
+
202
+ def _forced(protocol, body):
203
+ choice = body.get("tool_choice")
204
+ if isinstance(choice, dict):
205
+ return (choice.get("function") or choice).get("name")
206
+ return choice if choice in ("required", "none", "auto") else None
207
+
208
+
209
+ def _argument(name, spec, text, markers, tool_name, last_result):
210
+ if "enum" in spec:
211
+ for value in spec["enum"]:
212
+ if str(value).lower() in text.lower():
213
+ return value
214
+ return spec["enum"][0]
215
+ if spec.get("type") == "string":
216
+ if spec.get("pattern") and TOKEN.search(text):
217
+ return TOKEN.search(text).group(0)
218
+ if name == "path":
219
+ files = re.findall(r"[\w./-]+\.py", text)
220
+ tests = [f for f in files if f.startswith("test_")]
221
+ sources = [f for f in files if not f.startswith("test_")]
222
+ chosen = (tests if tool_name == "run_tests" else sources) or files
223
+ return chosen[0] if chosen else "main.py"
224
+ if name == "content":
225
+ return (last_result or "").replace(" - ", " + ") or "pass"
226
+ return markers[0] if markers else "value"
227
+ if spec.get("type") in ("integer", "number"):
228
+ return 1
229
+ if spec.get("type") == "boolean":
230
+ return True
231
+ return None
232
+
233
+
234
+ def _arguments(tool, text, markers, index, last_result):
235
+ schema = tool.get("parameters") or {}
236
+ out = {}
237
+ for name, spec in (schema.get("properties") or {}).items():
238
+ if name in schema.get("required", []) or True:
239
+ local = markers[index:index + 1] or markers[:1]
240
+ out[name] = _argument(name, spec, text, local, tool["name"], last_result)
241
+ return out
242
+
243
+
244
+ def _json_value(spec, text="", name=""):
245
+ if "const" in spec:
246
+ return spec["const"]
247
+ kind = spec.get("type")
248
+ if kind == "object":
249
+ return {k: _json_value(v, text, k) for k, v in spec.get("properties", {}).items()}
250
+ if kind == "array":
251
+ return [_json_value(spec.get("items", {}), text, name)]
252
+ if kind in ("integer", "number"):
253
+ return 2.8 if kind == "number" else 1
254
+ if kind == "boolean":
255
+ return True
256
+ if kind == "string":
257
+ stated = re.search(r"\b%s set to ([A-Za-z0-9_.-]+)" % re.escape(name), text) if name else None
258
+ return stated.group(1) if stated else spec.get("enum", ["x"])[0]
259
+ return None
260
+
261
+
262
+ def plan(protocol, body):
263
+ """Decide the model's reply: {'text', 'calls', 'finish'}; deterministic."""
264
+ turns, results, system, tools = _conversation(protocol, body)
265
+ user_text = " ".join(t for r, t in turns if r == "user")
266
+ last_user = next((t for r, t in reversed(turns) if r == "user"), "")
267
+ forced = _forced(protocol, body)
268
+ limit = body.get("max_tokens") or body.get("max_output_tokens") or 1000
269
+ by_name = {t["name"]: t for t in tools}
270
+ if tools and forced not in (None, "none", "auto") and not (forced == "required" and not tools):
271
+ names = [forced] if forced in by_name else [tools[0]["name"]]
272
+ markers = re.findall(r"\[\[(.+?)\]\]", last_user)
273
+ count = max(1, len(markers)) if forced == "required" else 1
274
+ calls = [{"name": names[0], "arguments": _arguments(by_name[names[0]], last_user, markers, i, results[-1] if results else None)}
275
+ for i in range(count)]
276
+ return {"text": "", "calls": calls, "finish": "tool_calls"}
277
+ schema = None
278
+ if protocol == "chat" and (body.get("response_format") or {}).get("type") == "json_schema":
279
+ schema = body["response_format"]["json_schema"]["schema"]
280
+ if protocol == "responses" and ((body.get("text") or {}).get("format") or {}).get("type") == "json_schema":
281
+ schema = body["text"]["format"]["schema"]
282
+ if schema is not None:
283
+ text = json.dumps(_json_value(schema, last_user))
284
+ elif results and turns and turns[-1][0] == "tool":
285
+ text = "Done. " + " ".join(results)
286
+ elif "code word" in last_user.lower() and TOKEN.search(user_text):
287
+ text = TOKEN.search(user_text).group(0)
288
+ elif any(re.search(r"exact marker", s) for s in system):
289
+ text = "Hello. " + TOKEN.search(" ".join(system)).group(0)
290
+ elif re.search(r"[Cc]ount from 1 to (\d+)", last_user):
291
+ text = "\n".join(str(i) for i in range(1, int(re.search(r"to (\d+)", last_user).group(1)) + 1))
292
+ elif re.search(r"numbers from 1 to (\d+)", last_user):
293
+ text = " ".join(str(i) for i in range(1, int(re.search(r"to (\d+)", last_user).group(1)) + 1))
294
+ else:
295
+ text = "OK"
296
+ words = text.split(" ") if "\n" not in text else text.split("\n")
297
+ finish = "stop"
298
+ if len(words) > limit:
299
+ text, finish = (" " if "\n" not in text else "\n").join(words[:limit]), "length"
300
+ return {"text": text, "calls": [], "finish": finish}
301
+
302
+
303
+ def _usage_counts(body, reply):
304
+ prompt = _words(json.dumps(body.get("messages") or body.get("input") or ""))
305
+ completion = _words(reply["text"]) + sum(_words(json.dumps(c["arguments"])) for c in reply["calls"])
306
+ return prompt, max(1, completion)
307
+
308
+
309
+ def _call_id(seed, index, prefix):
310
+ return prefix + hashlib.sha1(("%s-%d" % (seed, index)).encode()).hexdigest()[:12]
311
+
312
+
313
+ # ------------------------------------------------------------------ renderers
314
+
315
+ def chat_body(model_id, body, reply, request_id, faults):
316
+ prompt, completion = _usage_counts(body, reply)
317
+ message = {"role": "assistant", "content": reply["text"] or None}
318
+ if reply["calls"]:
319
+ message["tool_calls"] = [
320
+ {"id": None if faults.get("drop_tool_ids") else _call_id(request_id, i, "call_"), "type": "function",
321
+ "function": {"name": c["name"], "arguments": "{broken" if faults.get("bad_json_args") else json.dumps(c["arguments"])}}
322
+ for i, c in enumerate(reply["calls"])]
323
+ out = {"id": "chatcmpl-" + request_id, "object": "chat.completion", "created": 1_700_000_000, "model": model_id,
324
+ "choices": [{"index": 0, "message": message, "finish_reason": faults.get("finish_reason", reply["finish"])}]}
325
+ if not faults.get("drop_usage"):
326
+ out["usage"] = {"prompt_tokens": prompt, "completion_tokens": completion,
327
+ "total_tokens": prompt + completion + (1 if faults.get("bad_usage") else 0)}
328
+ return out
329
+
330
+
331
+ def chat_events(model_id, body, reply, request_id, faults):
332
+ base = {"id": "chatcmpl-" + request_id, "object": "chat.completion.chunk", "created": 1_700_000_000, "model": model_id}
333
+
334
+ def chunk(delta, finish=None, usage=None, choices=True):
335
+ data = dict(base)
336
+ data["choices"] = [{"index": 0, "delta": delta, "finish_reason": finish}] if choices else []
337
+ if usage:
338
+ data["usage"] = usage
339
+ return (None, json.dumps(data))
340
+
341
+ yield chunk({"role": "assistant", "content": ""})
342
+ pieces = reply["text"].split(" ") if "\n" not in reply["text"] else reply["text"].split("\n")
343
+ glue = " " if "\n" not in reply["text"] else "\n"
344
+ for i, piece in enumerate(pieces if reply["text"] else []):
345
+ yield chunk({"content": piece + (glue if i < len(pieces) - 1 else "")})
346
+ for index, call in enumerate(reply["calls"]):
347
+ arguments = json.dumps(call["arguments"])
348
+ yield chunk({"tool_calls": [{"index": index, "id": _call_id(request_id, index, "call_"), "type": "function",
349
+ "function": {"name": call["name"], "arguments": ""}}]})
350
+ for start in range(0, len(arguments), 7):
351
+ yield chunk({"tool_calls": [{"index": index, "function": {"arguments": arguments[start:start + 7]}}]})
352
+ yield chunk({}, finish=faults.get("finish_reason", reply["finish"]))
353
+ if body.get("stream_options", {}).get("include_usage"):
354
+ prompt, completion = _usage_counts(body, reply)
355
+ yield chunk({}, usage={"prompt_tokens": prompt, "completion_tokens": completion,
356
+ "total_tokens": prompt + completion}, choices=False)
357
+
358
+
359
+ def responses_object(model_id, body, reply, request_id, faults, status="completed"):
360
+ output = []
361
+ if reply["text"]:
362
+ output.append({"type": "message", "id": "msg_" + request_id, "role": "assistant", "status": "completed",
363
+ "content": [{"type": "output_text", "text": reply["text"], "annotations": []}]})
364
+ for i, call in enumerate(reply["calls"]):
365
+ output.append({"type": "function_call", "id": "fc_%s%d" % (request_id, i), "call_id": _call_id(request_id, i, "call_"),
366
+ "name": call["name"], "arguments": json.dumps(call["arguments"]), "status": "completed"})
367
+ prompt, completion = _usage_counts(body, reply)
368
+ out = {"id": "resp_" + request_id, "object": "response", "created_at": 1_700_000_000, "model": model_id,
369
+ "status": "incomplete" if reply["finish"] == "length" else status, "output": output}
370
+ if reply["finish"] == "length":
371
+ out["incomplete_details"] = {"reason": "max_output_tokens"}
372
+ if not faults.get("drop_usage"):
373
+ out["usage"] = {"input_tokens": prompt, "output_tokens": completion, "total_tokens": prompt + completion}
374
+ return out
375
+
376
+
377
+ def responses_events(model_id, body, reply, request_id, faults):
378
+ sequence = [0]
379
+
380
+ def event(kind, **data):
381
+ data.update(type=kind, sequence_number=sequence[0])
382
+ sequence[0] += 1
383
+ return (kind, json.dumps(data))
384
+
385
+ final = responses_object(model_id, body, reply, request_id, faults)
386
+ shell = dict(final, status="in_progress", output=[])
387
+ yield event("response.created", response=shell)
388
+ yield event("response.in_progress", response=shell)
389
+ for index, item in enumerate(final["output"]):
390
+ yield event("response.output_item.added", output_index=index, item=dict(item, status="in_progress"))
391
+ if item["type"] == "message":
392
+ yield event("response.content_part.added", output_index=index, content_index=0, item_id=item["id"],
393
+ part={"type": "output_text", "text": "", "annotations": []})
394
+ words = reply["text"].split(" ") if "\n" not in reply["text"] else reply["text"].split("\n")
395
+ glue = " " if "\n" not in reply["text"] else "\n"
396
+ for i, piece in enumerate(words):
397
+ yield event("response.output_text.delta", output_index=index, content_index=0, item_id=item["id"],
398
+ delta=piece + (glue if i < len(words) - 1 else ""))
399
+ yield event("response.output_text.done", output_index=index, content_index=0, item_id=item["id"], text=reply["text"])
400
+ else:
401
+ arguments = item["arguments"]
402
+ for start in range(0, len(arguments), 7):
403
+ yield event("response.function_call_arguments.delta", output_index=index, item_id=item["id"],
404
+ delta=arguments[start:start + 7])
405
+ yield event("response.function_call_arguments.done", output_index=index, item_id=item["id"], arguments=arguments)
406
+ yield event("response.output_item.done", output_index=index, item=item)
407
+ yield event("response.incomplete" if final["status"] == "incomplete" else "response.completed", response=final)
408
+
409
+
410
+ # --------------------------------------------------------------------- server
411
+
412
+ class _Handler(BaseHTTPRequestHandler):
413
+ owner = None
414
+ protocol_version = "HTTP/1.0"
415
+
416
+ def log_message(self, *args):
417
+ pass
418
+
419
+ def _send(self, status, body, headers=None, content_type="application/json"):
420
+ data = body if isinstance(body, bytes) else json.dumps(body).encode()
421
+ self.send_response(status)
422
+ self.send_header("Content-Type", content_type)
423
+ self.send_header("Content-Length", str(len(data)))
424
+ for key, value in (headers or {}).items():
425
+ self.send_header(key, str(value))
426
+ self.end_headers()
427
+ self.wfile.write(data)
428
+
429
+ def _error(self, status, code, message, retry_after=None, headers=None):
430
+ headers = dict(headers or {})
431
+ if retry_after:
432
+ headers["Retry-After"] = str(int(retry_after))
433
+ kind = "invalid_request_error" if status < 500 else "server_error"
434
+ if status == 401:
435
+ kind = "authentication_error"
436
+ elif status == 429:
437
+ kind = "rate_limit_error"
438
+ self._send(status, {"error": {"message": message, "type": kind, "param": None, "code": code}}, headers)
439
+
440
+ def _authorized(self):
441
+ if self.owner.open_api:
442
+ return True
443
+ auth = self.headers.get("Authorization", "")
444
+ return auth.startswith("Bearer ") and auth[7:] in self.owner.keys
445
+
446
+ def do_GET(self):
447
+ owner = self.owner
448
+ if self.path == "/management.html":
449
+ return self._send(200, b"<html>dashboard</html>", content_type="text/html")
450
+ if self.path == "/v1/models":
451
+ if not self._authorized():
452
+ return self._error(401, "invalid_api_key", "Missing or invalid API key.")
453
+ missing = owner.faults.get("missing_models", ())
454
+ data = [{"id": a, "object": "model", "created": 1_700_000_000, "owned_by": "cli-proxy"}
455
+ for a in owner.model_ids if a not in missing]
456
+ return self._send(200, {"object": "list", "data": data})
457
+ self._error(404, "not_found", "Unknown path.")
458
+
459
+ def do_POST(self):
460
+ owner = self.owner
461
+ owner.requests += 1
462
+ length = int(self.headers.get("Content-Length") or 0)
463
+ raw = self.rfile.read(length)
464
+ if not self._authorized():
465
+ return self._error(401, "invalid_api_key", "Missing or invalid API key.")
466
+ protocol = {"/v1/chat/completions": "chat", "/v1/responses": "responses"}.get(self.path)
467
+ if protocol is None:
468
+ return self._error(404, "not_found", "Unknown path.")
469
+ try:
470
+ body = json.loads(raw)
471
+ assert isinstance(body, dict)
472
+ except (ValueError, AssertionError):
473
+ return self._error(400, "invalid_json", "Request body is not valid JSON.")
474
+ faults = dict(owner.faults)
475
+ override = faults.get("status")
476
+ if override and faults.get("status_remaining", 1) > 0:
477
+ with owner.lock:
478
+ owner.faults["status_remaining"] = faults.get("status_remaining", 1) - 1
479
+ return self._error(override, "injected_fault", "Injected fault.", faults.get("retry_after"))
480
+ required = "messages" if protocol == "chat" else "input"
481
+ model_id = body.get("model")
482
+ if required not in body:
483
+ return self._error(400, "missing_required_parameter", "Missing required parameter: '%s'." % required)
484
+ if model_id not in owner.model_ids or model_id in faults.get("missing_models", ()):
485
+ return self._error(404, "model_not_found", "The model does not exist.")
486
+ failing = faults.get("model_status", {}).get(model_id)
487
+ if failing:
488
+ return self._error(failing, "injected_fault", "Injected fault.", faults.get("retry_after"))
489
+ affinity = self.headers.get("X-Session-Id") or body.get("user") or hashlib.sha1(
490
+ json.dumps(body.get("messages") or body.get("input"), sort_keys=True).encode()).hexdigest()[:8]
491
+ account, failure = owner.route(model_id, affinity)
492
+ if failure:
493
+ status, code, message, retry_after = failure
494
+ return self._error(status, code, message, retry_after)
495
+ headers = {"X-Emulator-Account": account.id}
496
+ request_id = hashlib.sha1(str(owner.requests).encode()).hexdigest()[:16]
497
+ reply = plan(protocol, body)
498
+ if faults.get("shared_token"): # simulates cross-request contamination
499
+ for call in reply["calls"]:
500
+ call["arguments"] = {k: ("AAA-0000" if isinstance(v, str) and TOKEN.fullmatch(v) else v)
501
+ for k, v in call["arguments"].items()}
502
+ if faults.get("stall_s"):
503
+ time.sleep(faults["stall_s"])
504
+ if not body.get("stream"):
505
+ if protocol == "chat":
506
+ return self._send(200, chat_body(model_id, body, reply, request_id, faults), headers)
507
+ return self._send(200, responses_object(model_id, body, reply, request_id, faults), headers)
508
+ self._stream(protocol, model_id, body, reply, request_id, faults, headers)
509
+
510
+ def _stream(self, protocol, model_id, body, reply, request_id, faults, headers):
511
+ owner = self.owner
512
+ self.send_response(200)
513
+ self.send_header("Content-Type", "text/event-stream")
514
+ self.send_header("Cache-Control", "no-cache")
515
+ for key, value in headers.items():
516
+ self.send_header(key, value)
517
+ self.end_headers()
518
+ events = (chat_events if protocol == "chat" else responses_events)(model_id, body, reply, request_id, faults)
519
+ try:
520
+ if faults.get("first_token_delay_s"):
521
+ time.sleep(faults["first_token_delay_s"])
522
+ for count, (name, data) in enumerate(events):
523
+ if faults.get("truncate_after") is not None and count >= faults["truncate_after"]:
524
+ return # connection closes with no terminal event or [DONE]
525
+ if faults.get("corrupt_frame") == count:
526
+ self.wfile.write(b"this is not an sse field\n\n")
527
+ frame = ("event: %s\n" % name if name else "") + "data: " + data + "\n\n"
528
+ self.wfile.write(frame.encode())
529
+ self.wfile.flush()
530
+ if owner.chunk_delay_s:
531
+ time.sleep(owner.chunk_delay_s)
532
+ if protocol == "chat" and not faults.get("omit_done"):
533
+ self.wfile.write(b"data: [DONE]\n\n")
534
+ self.wfile.flush()
535
+ except (BrokenPipeError, ConnectionResetError):
536
+ return # client cancelled
@@ -0,0 +1,88 @@
1
+ """Evaluate declarative expectations against a normalized response."""
2
+
3
+ import json
4
+ import re
5
+
6
+ from . import schema
7
+
8
+
9
+ def _lower(text):
10
+ return text.lower()
11
+
12
+
13
+ def _matches(actual, wanted):
14
+ if isinstance(wanted, dict):
15
+ if "regex" in wanted:
16
+ return isinstance(actual, str) and re.search(wanted["regex"], actual) is not None
17
+ if "min" in wanted or "max" in wanted:
18
+ return isinstance(actual, (int, float)) and wanted.get("min", actual) <= actual <= wanted.get("max", actual)
19
+ if isinstance(actual, str) and isinstance(wanted, str):
20
+ return actual.casefold() == wanted.casefold()
21
+ return actual == wanted
22
+
23
+
24
+ def evaluate(expect, norm, tool_schemas=None, max_gap=None):
25
+ """Return a list of (category, message); empty means every expectation held."""
26
+ found = [("protocol", message) for message in norm.violations]
27
+ if norm.upstream_error is not None:
28
+ found.append(("upstream", "provider error reported inside a successful response"))
29
+ reasons = expect.get("finish_reason")
30
+ if reasons and norm.finish_reason not in reasons:
31
+ found.append(("assertion", "finish_reason %r not in %s" % (norm.finish_reason, reasons)))
32
+ content = expect.get("content")
33
+ if content:
34
+ if content.get("non_empty") and not norm.content.strip():
35
+ found.append(("assertion", "assistant content is empty"))
36
+ needle = content.get("contains")
37
+ if needle and _lower(needle) not in _lower(norm.content):
38
+ found.append(("assertion", "assistant content does not contain the expected token"))
39
+ if content.get("max_chars") and len(norm.content) > content["max_chars"]:
40
+ found.append(("assertion", "assistant content exceeds max_chars"))
41
+ if expect.get("usage") and norm.usage is None:
42
+ found.append(("protocol", "usage metadata is missing"))
43
+ stream = expect.get("stream")
44
+ if stream:
45
+ if norm.events < stream.get("min_events", 1):
46
+ found.append(("assertion", "stream carried only %d events" % norm.events))
47
+ if stream.get("max_gap_s") and max_gap is not None and max_gap > stream["max_gap_s"]:
48
+ found.append(("assertion", "stream stalled %.1fs between events" % max_gap))
49
+ if "json" in expect:
50
+ try:
51
+ document = json.loads(norm.content)
52
+ except ValueError:
53
+ found.append(("assertion", "content is not valid JSON"))
54
+ else:
55
+ found += [("assertion", "json " + e) for e in schema.errors(document, expect["json"]["schema"])]
56
+ wanted = expect.get("tool_calls")
57
+ if wanted is not None:
58
+ found += _tool_calls(wanted, norm, tool_schemas or {})
59
+ return found
60
+
61
+
62
+ def _tool_calls(wanted, norm, tool_schemas):
63
+ calls = norm.tool_calls
64
+ found = []
65
+ if len(calls) < wanted.get("min", 0):
66
+ found.append(("assertion", "expected at least %d tool calls, got %d" % (wanted["min"], len(calls))))
67
+ if "max" in wanted and len(calls) > wanted["max"]:
68
+ found.append(("assertion", "expected at most %d tool calls, got %d" % (wanted["max"], len(calls))))
69
+ if wanted.get("unique_ids") and len({c["id"] for c in calls}) != len(calls):
70
+ found.append(("protocol", "tool call ids are not unique"))
71
+ for call in calls:
72
+ if wanted.get("names") and call["name"] not in wanted["names"]:
73
+ found.append(("assertion", "unexpected tool name %r" % call["name"]))
74
+ continue
75
+ if call["args"] is None:
76
+ continue # already reported as a protocol violation
77
+ if wanted.get("arguments_schema") == "from_tools" and call["name"] in tool_schemas:
78
+ found += [("assertion", "arguments " + e) for e in schema.errors(call["args"], tool_schemas[call["name"]])]
79
+ for field, value in (wanted.get("arguments") or {}).items():
80
+ if calls and not any(c["args"] and _matches(c["args"].get(field), value) for c in calls):
81
+ found.append(("assertion", "no tool call carried the expected %r argument" % field))
82
+ values = wanted.get("values")
83
+ if values:
84
+ seen = {c["args"].get(values["field"]) for c in calls if c["args"]}
85
+ missing = [v for v in values["include"] if v not in seen]
86
+ if missing:
87
+ found.append(("assertion", "tool calls missed expected %r values" % values["field"]))
88
+ return found