failproofai 1.0.3 → 1.0.4-beta.2

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 (251) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +5 -5
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
  42. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  43. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/index.html +1 -1
  46. package/.next/standalone/.next/server/app/index.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  48. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  83. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
  99. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
  103. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
  106. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  115. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
  120. package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
  121. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
  123. package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
  124. package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
  126. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
  128. package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
  130. package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
  135. package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
  136. package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
  137. package/.next/standalone/app/actions/get-leaks.ts +100 -0
  138. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
  139. package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
  140. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
  141. package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
  142. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
  143. package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
  144. package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
  145. package/.next/standalone/app/audit/audit-styles.css +114 -1
  146. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
  147. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
  148. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
  149. package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
  150. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
  151. package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
  152. package/.next/standalone/lib/auth/api-server-client.ts +28 -0
  153. package/.next/standalone/lib/claude-sessions.ts +101 -27
  154. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  155. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
  160. package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
  161. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  162. package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
  163. package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
  164. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  166. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  168. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
  169. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  170. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
  171. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  172. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  173. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  174. package/.next/standalone/node_modules/next/package.json +11 -11
  175. package/.next/standalone/package.json +14 -13
  176. package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
  177. package/.next/standalone/sdk/python/README.md +8 -0
  178. package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
  179. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  180. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
  181. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
  182. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
  183. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
  184. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
  185. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
  186. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
  187. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
  188. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
  189. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
  190. package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
  191. package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
  192. package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
  193. package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
  194. package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
  195. package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
  196. package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
  197. package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
  198. package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
  199. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
  200. package/.next/standalone/sdk/python/uv.lock +29 -29
  201. package/.next/standalone/server.js +1 -1
  202. package/README.md +13 -58
  203. package/dist/cli.mjs +5368 -8756
  204. package/dist/worker.mjs +539 -312
  205. package/lib/auth/api-server-client.ts +28 -0
  206. package/lib/claude-sessions.ts +101 -27
  207. package/package.json +14 -13
  208. package/pi-extension/index.ts +42 -4
  209. package/scripts/changelog-open.py +115 -0
  210. package/src/audit/cli.ts +134 -0
  211. package/src/audit/desktop-notify.ts +420 -0
  212. package/src/audit/harm-report.ts +101 -0
  213. package/src/audit/index.ts +141 -13
  214. package/src/audit/leak-fingerprint.ts +200 -0
  215. package/src/audit/leak-notice.ts +161 -0
  216. package/src/audit/leak-record.ts +232 -0
  217. package/src/audit/leak-scan.ts +292 -0
  218. package/src/audit/leak-store.ts +217 -0
  219. package/src/audit/macos-notifier.ts +310 -0
  220. package/src/audit/redact-example.ts +266 -14
  221. package/src/audit/report-harm.ts +12 -1
  222. package/src/audit/report.ts +6 -3
  223. package/src/audit/schedule-cli.ts +37 -0
  224. package/src/audit/scoring.ts +49 -0
  225. package/src/audit/types.ts +41 -0
  226. package/src/hooks/builtin-policies.ts +67 -2
  227. package/src/hooks/configure-wizard.ts +20 -0
  228. package/src/hooks/fp-config.ts +71 -11
  229. package/src/hooks/fp-home.ts +27 -0
  230. package/src/hooks/handler.ts +59 -1
  231. package/src/hooks/integrations.ts +47 -13
  232. package/src/hooks/notice.ts +155 -0
  233. package/src/hooks/uninstall-cli.ts +15 -0
  234. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
  235. package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
  236. package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
  237. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
  238. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
  240. package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
  241. package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
  242. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
  243. package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
  244. package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
  245. package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
  246. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
  247. package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
  248. package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
  249. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
  250. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
  251. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
@@ -0,0 +1,404 @@
1
+ """Evaluator definition registry and typed author results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import inspect
7
+ import json
8
+ import math
9
+ import re
10
+ from collections.abc import Awaitable, Callable, Mapping
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from failproofai_sdk.evaluator.protocol import (
15
+ MAX_CATALOG_DEFINITIONS,
16
+ MAX_DESCRIPTION_BYTES,
17
+ MAX_DISPLAY_NAME_BYTES,
18
+ MAX_DISPLAY_VALUE_BYTES,
19
+ MAX_EVAL_KEY_BYTES,
20
+ MAX_LABEL_BYTES,
21
+ MAX_LABELS_PER_RESULT,
22
+ MAX_REASONING_BYTES,
23
+ MAX_RESULTS_PER_RUN,
24
+ MAX_SUMMARY_BYTES,
25
+ MAX_UNIT_BYTES,
26
+ MAX_VERSION_BYTES,
27
+ CatalogDefinition,
28
+ ResultItem,
29
+ ResultKind,
30
+ SessionTranscript,
31
+ )
32
+
33
+ _KEY = re.compile(r"^[a-z][a-z0-9_]*$")
34
+ EvalFunction = Callable[[SessionTranscript], "EvalResult | Awaitable[EvalResult]"]
35
+ ConditionFunction = Callable[
36
+ [SessionTranscript], "bool | ConditionResult | Awaitable[bool | ConditionResult]"
37
+ ]
38
+ CancellationFunction = Callable[[SessionTranscript], "Any | Awaitable[Any]"]
39
+
40
+
41
+ def _bounded(value: str, *, field_name: str, maximum: int) -> str:
42
+ if not isinstance(value, str):
43
+ raise TypeError(f"{field_name} must be a string")
44
+ if not value:
45
+ raise ValueError(f"{field_name} must not be empty")
46
+ size = len(value.encode("utf-8"))
47
+ if size > maximum:
48
+ raise ValueError(f"{field_name} is {size} bytes; maximum is {maximum}")
49
+ # Reject C0 control characters and DEL, matching the server's `check_bounded`
50
+ # (server/src/evaluator/protocol.rs). Without this the SDK accepts a string —
51
+ # e.g. reasoning/summary quoting transcript text that contains an ANSI escape
52
+ # or NUL — that the server then rejects with a NON-RETRYABLE 422, so a
53
+ # successful evaluation is silently lost and its assignment dead-letters.
54
+ # TAB, LF and CR are kept because real multi-line reasoning uses them.
55
+ bad = next(
56
+ (
57
+ ch
58
+ for ch in value
59
+ if (ord(ch) < 0x20 and ch not in "\t\n\r") or ord(ch) == 0x7F
60
+ ),
61
+ None,
62
+ )
63
+ if bad is not None:
64
+ raise ValueError(
65
+ f"{field_name} must not contain control characters "
66
+ f"(found U+{ord(bad):04X})"
67
+ )
68
+ return value
69
+
70
+
71
+ def _finite(value: float, field_name: str) -> float:
72
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
73
+ raise TypeError(f"{field_name} must be a number")
74
+ result = float(value)
75
+ if not math.isfinite(result):
76
+ raise ValueError(f"{field_name} must be finite")
77
+ return result
78
+
79
+
80
+ def _labels(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
81
+ if len(values) > MAX_LABELS_PER_RESULT:
82
+ raise ValueError(f"at most {MAX_LABELS_PER_RESULT} labels are allowed")
83
+ normalized = []
84
+ for label in values:
85
+ normalized.append(_bounded(label, field_name="label", maximum=MAX_LABEL_BYTES))
86
+ if len(set(normalized)) != len(normalized):
87
+ raise ValueError("labels must be unique")
88
+ return tuple(sorted(normalized))
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Score:
93
+ value: float
94
+ passed: bool | None = None
95
+ unit: str = "ratio"
96
+ display_value: str | None = None
97
+ description: str | None = None
98
+
99
+ def __post_init__(self) -> None:
100
+ value = _finite(self.value, "score value")
101
+ if not 0 <= value <= 1:
102
+ raise ValueError("score value must be between 0 and 1")
103
+ object.__setattr__(self, "value", value)
104
+ if self.passed is not None and not isinstance(self.passed, bool):
105
+ raise TypeError("score passed must be a boolean or None")
106
+ _validate_result_text(self.unit, self.display_value, self.description)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Metric:
111
+ value: float
112
+ unit: str = ""
113
+ display_value: str | None = None
114
+ description: str | None = None
115
+
116
+ def __post_init__(self) -> None:
117
+ object.__setattr__(self, "value", _finite(self.value, "metric value"))
118
+ _validate_result_text(self.unit, self.display_value, self.description)
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Assertion:
123
+ passed: bool
124
+ description: str | None = None
125
+
126
+ def __post_init__(self) -> None:
127
+ if not isinstance(self.passed, bool):
128
+ raise TypeError("assertion passed must be a boolean")
129
+ if self.description is not None:
130
+ _bounded(
131
+ self.description,
132
+ field_name="description",
133
+ maximum=MAX_DESCRIPTION_BYTES,
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class ConditionResult:
139
+ applicable: bool
140
+ reason_code: str = "condition_false"
141
+
142
+ def __post_init__(self) -> None:
143
+ if not isinstance(self.applicable, bool):
144
+ raise TypeError("condition applicable must be a boolean")
145
+ _validate_key(self.reason_code, "condition reason code")
146
+
147
+
148
+ def _validate_result_text(
149
+ unit: str, display_value: str | None, description: str | None
150
+ ) -> None:
151
+ if unit:
152
+ _bounded(unit, field_name="unit", maximum=MAX_UNIT_BYTES)
153
+ if display_value is not None:
154
+ _bounded(
155
+ display_value,
156
+ field_name="display value",
157
+ maximum=MAX_DISPLAY_VALUE_BYTES,
158
+ )
159
+ if description is not None:
160
+ _bounded(
161
+ description,
162
+ field_name="description",
163
+ maximum=MAX_DESCRIPTION_BYTES,
164
+ )
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class EvalResult:
169
+ score: Score | None = None
170
+ metrics: Mapping[str, Metric | float] = field(default_factory=dict)
171
+ assertions: Mapping[str, Assertion | bool] = field(default_factory=dict)
172
+ reasoning: str | None = None
173
+ summary: str | None = None
174
+ labels: tuple[str, ...] = ()
175
+
176
+ def __post_init__(self) -> None:
177
+ if self.reasoning is not None:
178
+ _bounded(
179
+ self.reasoning,
180
+ field_name="reasoning",
181
+ maximum=MAX_REASONING_BYTES,
182
+ )
183
+ if self.summary is not None:
184
+ _bounded(self.summary, field_name="summary", maximum=MAX_SUMMARY_BYTES)
185
+ object.__setattr__(self, "labels", _labels(list(self.labels)))
186
+
187
+ def result_items(self, eval_key: str) -> tuple[ResultItem, ...]:
188
+ items: list[ResultItem] = []
189
+ if self.score is not None:
190
+ items.append(
191
+ ResultItem(
192
+ result_key=eval_key,
193
+ result_kind=ResultKind.SCORE,
194
+ numeric_value=self.score.value,
195
+ bool_value=self.score.passed,
196
+ unit=self.score.unit,
197
+ display_value=self.score.display_value,
198
+ description=self.score.description,
199
+ reasoning=self.reasoning,
200
+ labels=self.labels,
201
+ )
202
+ )
203
+ for key, raw_metric in sorted(self.metrics.items()):
204
+ _validate_key(key, "metric key")
205
+ metric = (
206
+ raw_metric if isinstance(raw_metric, Metric) else Metric(raw_metric)
207
+ )
208
+ items.append(
209
+ ResultItem(
210
+ result_key=key,
211
+ result_kind=ResultKind.METRIC,
212
+ numeric_value=metric.value,
213
+ unit=metric.unit,
214
+ display_value=metric.display_value,
215
+ description=metric.description,
216
+ # A metric-kind eval's primary result IS the metric whose
217
+ # key equals eval_key; attach the eval's reasoning there so
218
+ # it is not silently dropped for non-score evals.
219
+ reasoning=self.reasoning if key == eval_key else None,
220
+ labels=self.labels,
221
+ )
222
+ )
223
+ for key, raw_assertion in sorted(self.assertions.items()):
224
+ _validate_key(key, "assertion key")
225
+ assertion = (
226
+ raw_assertion
227
+ if isinstance(raw_assertion, Assertion)
228
+ else Assertion(raw_assertion)
229
+ )
230
+ items.append(
231
+ ResultItem(
232
+ result_key=key,
233
+ result_kind=ResultKind.ASSERTION,
234
+ bool_value=assertion.passed,
235
+ description=assertion.description,
236
+ # An assertion-kind eval's primary result is the assertion
237
+ # whose key equals eval_key; carry the eval's reasoning there
238
+ # so a non-score eval does not lose it.
239
+ reasoning=self.reasoning if key == eval_key else None,
240
+ labels=self.labels,
241
+ )
242
+ )
243
+ if not items:
244
+ raise ValueError("an EvalResult must contain a score, metric, or assertion")
245
+ if len(items) > MAX_RESULTS_PER_RUN:
246
+ raise ValueError(
247
+ f"an EvalResult may contain at most {MAX_RESULTS_PER_RUN} results"
248
+ )
249
+ keys = [item.result_key for item in items]
250
+ if len(keys) != len(set(keys)):
251
+ raise ValueError("result keys must be unique within one evaluation run")
252
+ return tuple(items)
253
+
254
+
255
+ def _validate_key(value: str, field_name: str = "eval_key") -> str:
256
+ _bounded(value, field_name=field_name, maximum=MAX_EVAL_KEY_BYTES)
257
+ if not _KEY.fullmatch(value):
258
+ raise ValueError(f"{field_name} must match {_KEY.pattern}")
259
+ return value
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class EvalDefinition:
264
+ eval_key: str
265
+ display_name: str
266
+ eval_version: str
267
+ result_kind: ResultKind
268
+ labels: tuple[str, ...]
269
+ function: EvalFunction
270
+ condition: ConditionFunction | None
271
+ on_cancel: CancellationFunction | None
272
+ timeout_seconds: float | None
273
+
274
+ def catalog_definition(self) -> CatalogDefinition:
275
+ return CatalogDefinition(
276
+ eval_key=self.eval_key,
277
+ display_name=self.display_name,
278
+ eval_version=self.eval_version,
279
+ result_kind=self.result_kind,
280
+ labels=self.labels,
281
+ )
282
+
283
+
284
+ class Evaluator:
285
+ """A process-local collection of explicitly versioned evaluations."""
286
+
287
+ def __init__(self, *, name: str, version: str) -> None:
288
+ self.name = _bounded(name, field_name="name", maximum=MAX_DISPLAY_NAME_BYTES)
289
+ self.version = _bounded(
290
+ version, field_name="version", maximum=MAX_VERSION_BYTES
291
+ )
292
+ self._definitions: dict[str, EvalDefinition] = {}
293
+
294
+ def eval(
295
+ self,
296
+ eval_key: str,
297
+ *,
298
+ version: str,
299
+ display_name: str | None = None,
300
+ result_kind: ResultKind | str = ResultKind.SCORE,
301
+ labels: tuple[str, ...] | list[str] = (),
302
+ when: ConditionFunction | None = None,
303
+ on_cancel: CancellationFunction | None = None,
304
+ timeout_seconds: float | None = None,
305
+ ) -> Callable[[EvalFunction], EvalFunction]:
306
+ key = _validate_key(eval_key)
307
+ eval_version = _bounded(
308
+ version, field_name="eval version", maximum=MAX_VERSION_BYTES
309
+ )
310
+ display = _bounded(
311
+ display_name or eval_key.replace("_", " ").capitalize(),
312
+ field_name="display name",
313
+ maximum=MAX_DISPLAY_NAME_BYTES,
314
+ )
315
+ kind = ResultKind(result_kind)
316
+ normalized_labels = _labels(list(labels))
317
+ if timeout_seconds is not None:
318
+ timeout_seconds = _finite(timeout_seconds, "timeout_seconds")
319
+ if timeout_seconds <= 0:
320
+ raise ValueError("timeout_seconds must be greater than zero")
321
+
322
+ def register(function: EvalFunction) -> EvalFunction:
323
+ if key in self._definitions:
324
+ raise ValueError(f"duplicate eval key: {key}")
325
+ if len(self._definitions) >= MAX_CATALOG_DEFINITIONS:
326
+ raise ValueError(
327
+ f"an evaluator may define at most {MAX_CATALOG_DEFINITIONS} evaluations"
328
+ )
329
+ if not callable(function):
330
+ raise TypeError("evaluation must be callable")
331
+ if when is not None and not callable(when):
332
+ raise TypeError("when must be callable")
333
+ if on_cancel is not None and not callable(on_cancel):
334
+ raise TypeError("on_cancel must be callable")
335
+ self._definitions[key] = EvalDefinition(
336
+ eval_key=key,
337
+ display_name=display,
338
+ eval_version=eval_version,
339
+ result_kind=kind,
340
+ labels=normalized_labels,
341
+ function=function,
342
+ condition=when,
343
+ on_cancel=on_cancel,
344
+ timeout_seconds=timeout_seconds,
345
+ )
346
+ return function
347
+
348
+ return register
349
+
350
+ @property
351
+ def definitions(self) -> tuple[EvalDefinition, ...]:
352
+ return tuple(self._definitions[key] for key in sorted(self._definitions))
353
+
354
+ def catalog(self) -> tuple[CatalogDefinition, ...]:
355
+ return tuple(definition.catalog_definition() for definition in self.definitions)
356
+
357
+ @property
358
+ def catalog_revision(self) -> str:
359
+ payload = [item.to_wire() for item in self.catalog()]
360
+ canonical = json.dumps(
361
+ payload,
362
+ ensure_ascii=False,
363
+ allow_nan=False,
364
+ sort_keys=True,
365
+ separators=(",", ":"),
366
+ ).encode("utf-8")
367
+ return "sha256:" + hashlib.sha256(canonical).hexdigest()
368
+
369
+ def definition(self, eval_key: str) -> EvalDefinition:
370
+ try:
371
+ return self._definitions[eval_key]
372
+ except KeyError as error:
373
+ raise KeyError(f"unknown eval key: {eval_key}") from error
374
+
375
+ def run_from_env(self) -> None:
376
+ """Run this evaluator until the process receives a stop request."""
377
+ import asyncio
378
+ import signal
379
+
380
+ from failproofai_sdk.evaluator.runtime import WorkerConfig, WorkerRuntime
381
+
382
+ async def run() -> None:
383
+ runtime = WorkerRuntime(self, WorkerConfig.from_env())
384
+ loop = asyncio.get_running_loop()
385
+ for name in ("SIGINT", "SIGTERM"):
386
+ process_signal = getattr(signal, name, None)
387
+ if process_signal is None:
388
+ continue
389
+ try:
390
+ loop.add_signal_handler(process_signal, runtime.stop)
391
+ except (NotImplementedError, RuntimeError):
392
+ pass
393
+ await runtime.run_forever()
394
+
395
+ asyncio.run(run())
396
+
397
+ @staticmethod
398
+ async def call(
399
+ function: EvalFunction | ConditionFunction, session: SessionTranscript
400
+ ) -> Any:
401
+ result = function(session)
402
+ if inspect.isawaitable(result):
403
+ return await result
404
+ return result
@@ -0,0 +1,299 @@
1
+ """Standard-library HTTP client for the Evaluator v2 worker protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import random
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from typing import Any
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import urljoin, urlsplit
13
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
14
+
15
+ from failproofai_sdk.evaluator.protocol import (
16
+ CLAIM_PATH,
17
+ DEFINITIONS_PATH,
18
+ HEARTBEAT_PATH,
19
+ LEASE_GENERATION_HEADER,
20
+ MAX_TRANSCRIPT_BYTES,
21
+ PLAN_PATH,
22
+ REGISTER_PATH,
23
+ RESULT_PATH,
24
+ WORKER_ID_HEADER,
25
+ Assignment,
26
+ DefinitionsResponse,
27
+ ClaimRequest,
28
+ ClaimResponse,
29
+ ErrorResponse,
30
+ HeartbeatRequest,
31
+ HeartbeatResponse,
32
+ PlanRequest,
33
+ PlanResponse,
34
+ RegisterRequest,
35
+ RegisterResponse,
36
+ ResultRequest,
37
+ ResultResponse,
38
+ SessionTranscript,
39
+ WireModel,
40
+ )
41
+
42
+ _DEFAULT_RESPONSE_LIMIT = 2 * 1024 * 1024
43
+ _RETRYABLE_HTTP_STATUSES = frozenset({429, 502, 503, 504})
44
+
45
+
46
+ class _RejectRedirects(HTTPRedirectHandler):
47
+ def redirect_request(self, request, file_pointer, code, message, headers, new_url):
48
+ return None
49
+
50
+
51
+ def _open_without_redirects(request: Request, *, timeout: float):
52
+ return build_opener(_RejectRedirects()).open(request, timeout=timeout)
53
+
54
+
55
+ class EvaluatorAPIError(RuntimeError):
56
+ def __init__(
57
+ self,
58
+ *,
59
+ status: int | None,
60
+ code: str,
61
+ message: str,
62
+ retryable: bool,
63
+ request_id: str | None = None,
64
+ ) -> None:
65
+ super().__init__(f"{code}: {message}")
66
+ self.status = status
67
+ self.code = code
68
+ self.retryable = retryable
69
+ self.request_id = request_id
70
+
71
+
72
+ class EvaluatorClient:
73
+ """Client for the public Evaluator v2 machine API.
74
+
75
+ Hosted workers normally use the FailproofAI dashboard origin. Its ``/v1``
76
+ passthrough forwards this worker's bearer credential to the private server.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ *,
82
+ base_url: str,
83
+ credential: str,
84
+ timeout_seconds: float = 30,
85
+ max_retries: int = 3,
86
+ allow_insecure_http: bool = False,
87
+ opener: Callable[..., Any] | None = None,
88
+ sleeper: Callable[[float], None] = time.sleep,
89
+ ) -> None:
90
+ parsed = urlsplit(base_url)
91
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
92
+ raise ValueError("base_url must be an absolute http(s) URL")
93
+ hostname = parsed.hostname
94
+ loopback = hostname == "localhost"
95
+ if hostname is not None and not loopback:
96
+ try:
97
+ loopback = ipaddress.ip_address(hostname).is_loopback
98
+ except ValueError:
99
+ loopback = False
100
+ if parsed.scheme != "https" and not loopback and not allow_insecure_http:
101
+ raise ValueError("base_url must use https unless it targets loopback")
102
+ if not credential or not credential.strip():
103
+ raise ValueError("credential must not be empty")
104
+ if any(
105
+ ord(character) < 32 or ord(character) == 127 for character in credential
106
+ ):
107
+ raise ValueError("credential must not contain control characters")
108
+ if timeout_seconds <= 0:
109
+ raise ValueError("timeout_seconds must be greater than zero")
110
+ if max_retries < 0:
111
+ raise ValueError("max_retries must not be negative")
112
+ self._base_url = base_url.rstrip("/") + "/"
113
+ self._origin = (parsed.scheme, parsed.netloc)
114
+ self._credential = credential
115
+ self._timeout_seconds = timeout_seconds
116
+ self._max_retries = max_retries
117
+ self._opener = opener or _open_without_redirects
118
+ self._sleeper = sleeper
119
+
120
+ def register(self, request: RegisterRequest) -> RegisterResponse:
121
+ return RegisterResponse.from_wire(
122
+ self._json("POST", REGISTER_PATH, request, retry=True)
123
+ )
124
+
125
+ def claim(self, request: ClaimRequest) -> ClaimResponse:
126
+ # A lost claim response may already have leased work. Do not hide a
127
+ # second claim behind transport retry; the runtime recalculates capacity.
128
+ return ClaimResponse.from_wire(
129
+ self._json("POST", CLAIM_PATH, request, retry=False)
130
+ )
131
+
132
+ def transcript(
133
+ self, assignment: Assignment, *, worker_id: str
134
+ ) -> SessionTranscript:
135
+ headers = {
136
+ WORKER_ID_HEADER: worker_id,
137
+ LEASE_GENERATION_HEADER: str(assignment.lease_generation),
138
+ }
139
+ return SessionTranscript.from_wire(
140
+ self._json(
141
+ "GET",
142
+ assignment.transcript_url,
143
+ None,
144
+ retry=True,
145
+ headers=headers,
146
+ response_limit=MAX_TRANSCRIPT_BYTES,
147
+ )
148
+ )
149
+
150
+ def definitions(
151
+ self, assignment: Assignment, *, worker_id: str
152
+ ) -> DefinitionsResponse:
153
+ headers = {
154
+ WORKER_ID_HEADER: worker_id,
155
+ LEASE_GENERATION_HEADER: str(assignment.lease_generation),
156
+ }
157
+ path = assignment.definitions_url or DEFINITIONS_PATH.format(
158
+ assignment_id=assignment.assignment_id
159
+ )
160
+ return DefinitionsResponse.from_wire(
161
+ self._json("GET", path, None, retry=True, headers=headers)
162
+ )
163
+
164
+ def plan(self, assignment_id: str, request: PlanRequest) -> PlanResponse:
165
+ return PlanResponse.from_wire(
166
+ self._json(
167
+ "POST",
168
+ PLAN_PATH.format(assignment_id=assignment_id),
169
+ request,
170
+ retry=True,
171
+ )
172
+ )
173
+
174
+ def heartbeat(self, request: HeartbeatRequest) -> HeartbeatResponse:
175
+ return HeartbeatResponse.from_wire(
176
+ self._json("POST", HEARTBEAT_PATH, request, retry=True)
177
+ )
178
+
179
+ def submit_result(self, run_id: str, request: ResultRequest) -> ResultResponse:
180
+ return ResultResponse.from_wire(
181
+ self._json(
182
+ "POST",
183
+ RESULT_PATH.format(evaluation_run_id=run_id),
184
+ request,
185
+ retry=True,
186
+ )
187
+ )
188
+
189
+ def _url(self, path: str) -> str:
190
+ url = urljoin(self._base_url, path)
191
+ parsed = urlsplit(url)
192
+ if (parsed.scheme, parsed.netloc) != self._origin:
193
+ raise EvaluatorAPIError(
194
+ status=None,
195
+ code="invalid_transcript_url",
196
+ message="server supplied a URL outside the configured API origin",
197
+ retryable=False,
198
+ )
199
+ return url
200
+
201
+ def _json(
202
+ self,
203
+ method: str,
204
+ path: str,
205
+ body: WireModel | None,
206
+ *,
207
+ retry: bool,
208
+ headers: Mapping[str, str] | None = None,
209
+ response_limit: int = _DEFAULT_RESPONSE_LIMIT,
210
+ ) -> dict[str, Any]:
211
+ encoded = None
212
+ request_headers = {
213
+ "Accept": "application/json",
214
+ "Authorization": f"Bearer {self._credential}",
215
+ "User-Agent": "failproofai-sdk-evaluator/2",
216
+ }
217
+ if body is not None:
218
+ encoded = json.dumps(
219
+ body.to_wire(),
220
+ allow_nan=False,
221
+ ensure_ascii=False,
222
+ separators=(",", ":"),
223
+ ).encode("utf-8")
224
+ request_headers["Content-Type"] = "application/json"
225
+ if headers:
226
+ request_headers.update(headers)
227
+
228
+ attempts = self._max_retries + 1 if retry else 1
229
+ for attempt in range(attempts):
230
+ request = Request(
231
+ self._url(path), data=encoded, headers=request_headers, method=method
232
+ )
233
+ try:
234
+ with self._opener(request, timeout=self._timeout_seconds) as response:
235
+ return self._decode(
236
+ response.read(response_limit + 1), response_limit
237
+ )
238
+ except HTTPError as error:
239
+ api_error = self._http_error(error, response_limit)
240
+ if attempt + 1 == attempts or not api_error.retryable:
241
+ raise api_error from error
242
+ except (URLError, TimeoutError, OSError) as error:
243
+ if attempt + 1 == attempts:
244
+ raise EvaluatorAPIError(
245
+ status=None,
246
+ code="transport_error",
247
+ message=str(error),
248
+ retryable=True,
249
+ ) from error
250
+ # Jitter is scheduling noise, not a security decision.
251
+ self._sleeper(random.uniform(0, min(0.25 * (2**attempt), 2.0))) # nosec B311
252
+ raise AssertionError("retry loop exhausted without returning or raising")
253
+
254
+ @staticmethod
255
+ def _decode(raw: bytes, limit: int) -> dict[str, Any]:
256
+ if len(raw) > limit:
257
+ raise EvaluatorAPIError(
258
+ status=None,
259
+ code="response_too_large",
260
+ message=f"server response exceeds {limit} bytes",
261
+ retryable=False,
262
+ )
263
+ try:
264
+ value = json.loads(raw)
265
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
266
+ raise EvaluatorAPIError(
267
+ status=None,
268
+ code="invalid_response",
269
+ message="server response was not valid JSON",
270
+ retryable=False,
271
+ ) from error
272
+ if not isinstance(value, dict):
273
+ raise EvaluatorAPIError(
274
+ status=None,
275
+ code="invalid_response",
276
+ message="server response must be a JSON object",
277
+ retryable=False,
278
+ )
279
+ return value
280
+
281
+ @classmethod
282
+ def _http_error(cls, error: HTTPError, limit: int) -> EvaluatorAPIError:
283
+ raw = error.read(limit + 1)
284
+ try:
285
+ response = ErrorResponse.from_wire(cls._decode(raw, limit))
286
+ except (ValueError, EvaluatorAPIError):
287
+ return EvaluatorAPIError(
288
+ status=error.code,
289
+ code="http_error",
290
+ message=f"server returned HTTP {error.code}",
291
+ retryable=error.code in _RETRYABLE_HTTP_STATUSES,
292
+ )
293
+ return EvaluatorAPIError(
294
+ status=error.code,
295
+ code=response.error.code,
296
+ message=response.error.message,
297
+ retryable=response.error.retryable,
298
+ request_id=response.error.request_id,
299
+ )