failproofai 1.0.7-beta.2 → 1.0.8-beta.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 (225) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -5
  23. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  31. package/.next/standalone/.next/server/app/audit/page.js +4 -4
  32. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/index.html +1 -1
  35. package/.next/standalone/.next/server/app/index.rsc +15 -15
  36. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  37. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  43. package/.next/standalone/.next/server/app/policies/page.js +6 -7
  44. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  55. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +18 -7
  58. package/.next/standalone/.next/server/app/settings/page.js +9 -7
  59. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/chunks/{[externals]__1j-zsg5._.js → [externals]__20kzpkf._.js} +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l3yhx4._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
  65. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
  66. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +9 -9
  69. package/.next/standalone/.next/server/chunks/_1q5i8mb._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
  73. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0qxnccm._.js → [root-of-the-server]__06pflha._.js} +2 -4
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0soxz2z._.js → [root-of-the-server]__12e7nhs._.js} +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0l44ual._.js → [root-of-the-server]__14o3ek1._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/{_1v-jvrv._.js → _0o4xkpl._.js} +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/{_1mel6y1._.js → _1-7sqrb._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +13 -13
  97. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
  105. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
  106. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +12 -0
  107. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
  113. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  114. package/.next/standalone/.next/server/pages/404.html +1 -1
  115. package/.next/standalone/.next/server/pages/500.html +1 -1
  116. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.json +33 -22
  118. package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +1 -0
  119. package/.next/standalone/.next/static/chunks/{3brze37td_wnc.js → 0ahfwmbkpgfw4.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{043j99m8ykg__.css → 0bhidk90e-07f.css} +1 -1
  121. package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{3ugmd_7dyn0id.js → 1v_tp3hm8wyhe.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +1 -0
  124. package/.next/standalone/.next/static/chunks/{3yxro_r2_o9ad.js → 2vo7qbdbvnc0o.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{0fqd7m_u81mi5.js → 2z7i-yg59w4pn.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/36uhh9el_oz8e.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{129ag2bw93bdh.js → 3bgot5v6f6s3c.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{3otmypm6j_xfo.js → 3o3f1ibfci0p7.js} +2 -2
  129. package/.next/standalone/SECURITY.md +53 -0
  130. package/.next/standalone/app/actions/get-jev-config.ts +194 -14
  131. package/.next/standalone/app/actions/update-jev-config.ts +160 -11
  132. package/.next/standalone/app/policies/hooks-client.tsx +5 -3
  133. package/.next/standalone/app/settings/jev-panel.tsx +169 -12
  134. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
  135. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
  136. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
  137. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
  138. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
  139. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
  140. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
  141. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
  142. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
  143. package/.next/standalone/package.json +9 -9
  144. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  145. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  146. package/.next/standalone/sdk/typescript/CHANGELOG.md +15 -1
  147. package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package-lock.json +10 -30
  148. package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package.json +3 -0
  149. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package-lock.json +4 -16
  150. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package.json +3 -0
  151. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package-lock.json +13 -132
  152. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package.json +4 -0
  153. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package-lock.json +4 -142
  154. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package.json +4 -0
  155. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package-lock.json +1396 -1016
  156. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package.json +9 -0
  157. package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
  158. package/.next/standalone/server.js +1 -1
  159. package/README.md +1 -0
  160. package/bin/failproofai.mjs +37 -6
  161. package/dist/cli.mjs +2861 -1145
  162. package/dist/worker.mjs +815 -197
  163. package/package.json +9 -9
  164. package/src/hooks/builtin-policies.ts +24 -6
  165. package/src/hooks/cloud-connection.ts +190 -1
  166. package/src/hooks/cloud-enrollment-cli.ts +56 -7
  167. package/src/hooks/cloud-introspect.ts +6 -0
  168. package/src/hooks/configure-wizard.ts +26 -6
  169. package/src/hooks/custom-hooks-loader.ts +58 -8
  170. package/src/hooks/effective-reviewers.ts +172 -8
  171. package/src/hooks/flush-cli.ts +35 -8
  172. package/src/hooks/fp-config.ts +266 -1
  173. package/src/hooks/fp-reset.ts +22 -4
  174. package/src/hooks/handler.ts +44 -7
  175. package/src/hooks/hook-activity-store.ts +13 -5
  176. package/src/hooks/jev-cli.ts +1009 -86
  177. package/src/hooks/jev-cloud-connection.ts +392 -0
  178. package/src/hooks/manager.ts +44 -6
  179. package/src/hooks/pack-cli.ts +175 -37
  180. package/src/hooks/pack-failclosed.ts +3 -0
  181. package/src/hooks/pack-manifest.ts +11 -1
  182. package/src/hooks/pack-store.ts +47 -10
  183. package/src/hooks/policy-authority.ts +73 -20
  184. package/src/hooks/policy-catalog.ts +10 -8
  185. package/src/hooks/policy-evaluator.ts +31 -8
  186. package/src/hooks/policy-registry.ts +3 -2
  187. package/src/hooks/policy-reviewability.ts +41 -12
  188. package/src/hooks/policy-types.ts +5 -3
  189. package/src/hooks/semantic/combine.ts +124 -84
  190. package/src/hooks/semantic/decide.ts +16 -0
  191. package/src/hooks/semantic/envelope.ts +18 -59
  192. package/src/hooks/semantic/evaluator.ts +11 -2
  193. package/src/hooks/semantic/facts.ts +7 -1
  194. package/src/hooks/semantic/intent.ts +8 -8
  195. package/src/hooks/semantic/jev-client.ts +540 -28
  196. package/src/hooks/semantic/jev-config.ts +586 -38
  197. package/src/hooks/semantic/jev-review.ts +26 -11
  198. package/src/hooks/semantic/jev-throttle.ts +8 -2
  199. package/src/hooks/semantic/pack-policies.ts +58 -15
  200. package/src/hooks/semantic/policies.ts +4 -3
  201. package/src/hooks/semantic/redact.ts +28 -1
  202. package/src/hooks/semantic/session-root.ts +116 -0
  203. package/src/hooks/semantic/types.ts +11 -0
  204. package/src/hooks/tui.ts +4 -0
  205. package/src/hooks/worker-server.ts +2 -1
  206. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1bf34x4._.js +0 -22
  208. package/.next/standalone/.next/server/chunks/src_hooks_01frwmb._.js +0 -5
  209. package/.next/standalone/.next/server/chunks/src_hooks_18qtd42._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s_yomn._.js +0 -4
  211. package/.next/standalone/.next/server/chunks/ssr/_1feuvhb._.js +0 -5
  212. package/.next/standalone/.next/server/chunks/ssr/_1w_5l7t._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/ssr/src_hooks_095a_79._.js +0 -5
  214. package/.next/standalone/.next/server/chunks/ssr/src_hooks_15t8kqj._.js +0 -3
  215. package/.next/standalone/.next/server/chunks/ssr/src_hooks_18k8rl0._.js +0 -12
  216. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1fm2w5z._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1j0zy3v._.js +0 -3
  218. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
  219. package/.next/standalone/.next/static/chunks/1qd741hzlmjbo.js +0 -1
  220. package/.next/standalone/.next/static/chunks/2_pltstd8-xgs.js +0 -1
  221. package/.next/standalone/.next/static/chunks/2c8j9l6j_b1ci.js +0 -1
  222. package/.next/standalone/.next/static/chunks/3-k569wzcli8q.js +0 -1
  223. /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_buildManifest.js +0 -0
  224. /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_clientMiddlewareManifest.js +0 -0
  225. /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_ssgManifest.js +0 -0
@@ -10,8 +10,28 @@
10
10
  * enabled policy set Jev is allowed to clear, and how Jev has
11
11
  * been doing (fallbacks, latency, clears) — never the key
12
12
  * jev test one tiny live request: latency and the Jev version that answered
13
+ * jev models what `GET <base>/models` says this endpoint serves
13
14
  * jev remove delete the file; hooks go back to the regex engine unchanged
14
15
  *
16
+ * # `--url` is a BASE, and the model has to exist
17
+ *
18
+ * Two contract mistakes cost a customer on a LiteLLM proxy real time, and both
19
+ * were things this command already knew enough to say:
20
+ *
21
+ * - `--url https://…/typesafe/v1/models` was an ENDPOINT given where a BASE
22
+ * belongs. It saved without complaint, `/systemone` was appended to it, and the
23
+ * only signal was `http-404: Not Found` from `jev test`. Setup now refuses a URL
24
+ * whose path ends in `/models`, `/chat/completions`, `/completions`,
25
+ * `/embeddings` or `/systemone` and names the base it implies
26
+ * (`endpointGivenAsBase`).
27
+ * - the model then still did not exist: `custom`'s default is `jev-1.13.0` and
28
+ * that proxy serves `jev-latest` and `jev-preview`. Setup now reads
29
+ * `GET <base>/models` before it writes, and refuses a model the list does not
30
+ * carry, naming the ones it does. When the list cannot be read — no key yet, a
31
+ * proxy that serves no list, Cloudflare, an unreachable host — setup proceeds
32
+ * exactly as it did before: this is a check where one is possible, never a
33
+ * dependency on a model-list endpoint existing.
34
+ *
15
35
  * # The provider is read off the URL
16
36
  *
17
37
  * `--url` names the endpoint, and its HOST says which provider that is
@@ -80,25 +100,42 @@ import { writeJsonAtomically } from "../../lib/atomic-write";
80
100
  import {
81
101
  DEFAULT_JEV_MODE,
82
102
  JEV_API_KEY_ENV,
103
+ JEV_CLOUD_PROVIDER,
83
104
  JEV_CONFIG_DEFAULT_TIMEOUT_MS,
84
105
  JEV_PROVIDER_KINDS,
106
+ MAX_JEV_TIMEOUT_MS,
107
+ endpointGivenAsBase,
85
108
  inspectJevConfig,
109
+ jevCloudBaseUrl,
86
110
  jevConfigPath,
111
+ providerForUrl,
112
+ providerHostConflict,
87
113
  readJevConfigFileForUpdate,
88
114
  validateApiKey,
89
115
  validateBaseUrl,
90
116
  validateJevConfig,
117
+ type EndpointGivenAsBase,
91
118
  type JevConfig,
119
+ type JevConfigInspection,
92
120
  type JevProviderKind,
93
121
  } from "./semantic/jev-config";
94
122
  import {
123
+ JEV_MODEL_LIST_TIMEOUT_MS,
95
124
  JEV_PROVIDER_DEFAULTS,
96
125
  JevError,
97
126
  displayEndpoint,
127
+ endpointAsBaseReason,
128
+ jevModelsUrl,
98
129
  jevRoute,
130
+ listDescribesSystemOne,
131
+ modelListHasModel,
132
+ modelsUrlForBase,
99
133
  readAnswers,
134
+ readJevModelList,
100
135
  scrubSecret,
101
136
  transportForConfig,
137
+ type JevModelListRead,
138
+ type JevModelListResult,
102
139
  } from "./semantic/jev-client";
103
140
  import {
104
141
  reviewableProblem,
@@ -107,8 +144,9 @@ import {
107
144
  type ReviewableCoverage,
108
145
  } from "./policy-reviewability";
109
146
  import { jevStats, type JevStats } from "./semantic/jev-stats";
147
+ import { readCredentials, readJevCloudCredential, type JevCloudCredential } from "./fp-config";
110
148
  import type { JevRequest } from "./semantic/types";
111
- import { emptyState, nextStep, note, optsFor, rows, rule, stack, title, warning, type RenderOpts } from "./tui";
149
+ import { TOKEN_ON_ARGV, emptyState, nextStep, note, optsFor, rows, rule, stack, title, warning, type RenderOpts } from "./tui";
112
150
 
113
151
  export interface JevCliResult {
114
152
  lines: string[];
@@ -117,6 +155,9 @@ export interface JevCliResult {
117
155
  json?: string;
118
156
  }
119
157
 
158
+ /** `GET <base>/models`, as `setup` and `models` reach it. */
159
+ export type JevModelListReader = (modelsUrl: string, apiKey: string | null) => Promise<JevModelListResult>;
160
+
120
161
  export interface JevCliDeps {
121
162
  /** Everything piped on stdin (for `--key-stdin` off a terminal). */
122
163
  readStdin?: () => Promise<string>;
@@ -125,9 +166,19 @@ export interface JevCliDeps {
125
166
  stdinIsTTY?: boolean;
126
167
  /** Budget for `jev test`'s single request. */
127
168
  testTimeoutMs?: number;
169
+ /**
170
+ * How `<base>/models` is read. The default reaches the network, which `setup`
171
+ * did not used to do at all — so it is injectable, and a unit test that is not
172
+ * about the list injects a reader that reads nothing rather than calling out to
173
+ * a provider from a test run.
174
+ */
175
+ readModelList?: JevModelListReader;
128
176
  render?: RenderOpts;
129
177
  }
130
178
 
179
+ /** The real reader, on its own budget: see `JEV_MODEL_LIST_TIMEOUT_MS`. */
180
+ const liveModelListReader: JevModelListReader = (url, apiKey) => readJevModelList(url, apiKey, AbortSignal.timeout(JEV_MODEL_LIST_TIMEOUT_MS));
181
+
131
182
  const ok = (lines: string[], json?: string): JevCliResult => ({ lines, exitCode: 0, ...(json !== undefined ? { json } : {}) });
132
183
  const fail = (lines: string[], json?: string): JevCliResult => ({ lines, exitCode: 1, ...(json !== undefined ? { json } : {}) });
133
184
 
@@ -135,8 +186,10 @@ export const JEV_USAGE = [
135
186
  "Usage:",
136
187
  " failproofai jev --url <url> [--key-stdin | --token <token>] [options]",
137
188
  " failproofai jev setup --provider <kind> [--key-stdin | --key-from-env] [options]",
189
+ " failproofai jev setup --provider failproofai [--mode off|shadow|enforce] (FailproofAI Cloud: no key, no URL)",
138
190
  " failproofai jev status [--json]",
139
191
  " failproofai jev test [--json]",
192
+ " failproofai jev models [--provider <kind>] [--url <base>] [--json]",
140
193
  " failproofai jev remove",
141
194
  ];
142
195
 
@@ -213,11 +266,84 @@ function parseFlags(argv: string[], allowed: Set<string>): Parsed | string {
213
266
  // ── Rendering helpers ────────────────────────────────────────────────────────
214
267
 
215
268
  function modeLine(mode: NonNullable<JevConfig["mode"]>): string {
269
+ if (mode === "off") return "off — Jev is not asked at all; the regex policies decide alone";
216
270
  return mode === "enforce"
217
271
  ? "enforce — Jev's verdicts apply: it may clear a reviewable policy's deny and add its own"
218
272
  : "shadow — Jev is asked and logged; the regex result is what is enforced";
219
273
  }
220
274
 
275
+ /**
276
+ * The name a provider is SHOWN by. Only the Cloud route differs from its kind:
277
+ * `failproofai` is the value in the file, and "FailproofAI Cloud" is what the
278
+ * person connected to.
279
+ */
280
+ export function providerLabel(provider: JevProviderKind): string {
281
+ return provider === JEV_CLOUD_PROVIDER ? "FailproofAI Cloud" : provider;
282
+ }
283
+
284
+ /**
285
+ * Where requests go, as `jev status` shows it. The Cloud route shows its HOST
286
+ * only: the path under it is the Cloud's own routing, the same on every
287
+ * deployment, and the host is the one fact that says which Cloud this machine
288
+ * is talking to.
289
+ */
290
+ function shownEndpoint(provider: JevProviderKind, endpoint: string): string {
291
+ if (provider !== JEV_CLOUD_PROVIDER) return displayEndpoint(endpoint);
292
+ try {
293
+ return new URL(endpoint).host;
294
+ } catch {
295
+ return "(invalid URL)";
296
+ }
297
+ }
298
+
299
+ /** What `jev status` says about where the key comes from. */
300
+ const CLOUD_KEY_SOURCE = "FailproofAI Cloud connection";
301
+
302
+ /** Whether this machine holds a usable Jev credential, read owner-only. Never the key. */
303
+ function cloudCredentialPresent(): boolean {
304
+ return cloudConnection().keyCarriesJev;
305
+ }
306
+
307
+ /**
308
+ * Whether a refused or missing jev.json is fixed from the Cloud connection: it
309
+ * names the Cloud, or it names no provider (absent, not JSON) on a machine whose
310
+ * key carries Jev — which is what `config --token` suggests for the same file.
311
+ */
312
+ function cloudRoute(raw: Record<string, unknown> | null | undefined): boolean {
313
+ return raw?.provider === JEV_CLOUD_PROVIDER || (raw?.provider === undefined && cloudCredentialPresent());
314
+ }
315
+
316
+ /**
317
+ * The two facts `status --json` reports about the FailproofAI Cloud connection,
318
+ * read owner-only: whether this machine is connected at all, and whether the
319
+ * key it connected with carries Jev. Never the key.
320
+ */
321
+ function cloudConnection(): { cloudConnected: boolean; keyCarriesJev: boolean } {
322
+ try {
323
+ const read = readJevCloudCredential();
324
+ if (read.status === "ok") return { cloudConnected: true, keyCarriesJev: true };
325
+ if (read.status === "absent") return { cloudConnected: read.connected, keyCarriesJev: false };
326
+ // Refused (a loose or unreadable credentials.json): no key is usable, but a
327
+ // connection on record is still a connection — as the dashboard reads it.
328
+ const creds = readCredentials();
329
+ return { cloudConnected: Boolean(creds.cloud?.url ?? creds.ingest?.url), keyCarriesJev: false };
330
+ } catch {
331
+ return { cloudConnected: false, keyCarriesJev: false };
332
+ }
333
+ }
334
+
335
+ /** The one line `status` titles a connected machine whose key has no Jev with. */
336
+ const KEY_LACKS_JEV_TITLE = "off — no Jev key is stored for this machine's FailproofAI Cloud connection";
337
+
338
+ /** The `key` row for a Cloud file, saying which of the three states the connection is in. */
339
+ function cloudKeyRow(): string {
340
+ const c = cloudConnection();
341
+ if (c.keyCarriesJev) return CLOUD_KEY_SOURCE;
342
+ return c.cloudConnected
343
+ ? `${CLOUD_KEY_SOURCE} — connected, no Jev key stored for it`
344
+ : `${CLOUD_KEY_SOURCE} — this machine is not connected`;
345
+ }
346
+
221
347
  /** `0600`, or null when unknown. */
222
348
  function octal(mode: number | null): string | null {
223
349
  return mode === null ? null : mode.toString(8).padStart(4, "0");
@@ -238,10 +364,68 @@ function scrubbed(e: { code: string; message: string }, key: string): { code: st
238
364
  return { code: e.code, message: scrubSecret(e.message, key) };
239
365
  }
240
366
 
367
+ /**
368
+ * What the person should do about a failed FailproofAI Cloud request, by cause,
369
+ * or null where the generic advice already fits. The remedies differ from a
370
+ * BYOK provider's because the key and the budget are the Cloud connection's and
371
+ * the org's, not anything `jev setup` holds.
372
+ */
373
+ function cloudRemedy(code: string, message = ""): string | null {
374
+ if (code === "http-401" || code === "http-403") {
375
+ return "FailproofAI Cloud refused this machine's key: it was revoked, or it does not carry jev:evaluate. Reconnect with a key that does (the \"machine\" preset on the Keys page): failproofai config --token <key>";
376
+ }
377
+ if (code === "out-of-credits") {
378
+ return "Your FailproofAI Cloud org has used its plan allowance (HTTP 402). Until it resets or the plan changes, hooks fall back to regex.";
379
+ }
380
+ if (code === "http-429") {
381
+ // Two limits answer 429 (contract decision 18): the per-minute ones, and
382
+ // the org's daily Jev cap, whose body says `daily_limit_reached` and whose
383
+ // Retry-After runs to the next UTC midnight. Only the words tell them
384
+ // apart, and "right now … at most 60 s" is wrong for the second: the
385
+ // machine still asks again after at most 60 s, and keeps being refused.
386
+ if (/\bdaily_limit_reached\b/.test(message)) {
387
+ return "Daily Jev limit for this org reached; resets at 00:00 UTC. Until then hooks fall back to regex; the machine asks FailproofAI Cloud again at most once a minute (Retry-After, capped at 60 s).";
388
+ }
389
+ return "FailproofAI Cloud is rate-limiting Jev for this org right now. Hooks fall back to regex, and send it nothing more until the wait it asked for (Retry-After, at most 60 s) is over.";
390
+ }
391
+ if (code === "http-404") return "This FailproofAI Cloud does not serve Jev (its server predates the Jev route). Hooks fall back to regex until it does.";
392
+ if (code === "http-503") {
393
+ return (
394
+ "This FailproofAI Cloud cannot serve Jev for your org: its deployment has no model gateway, the org is not " +
395
+ "provisioned yet, or the gateway is down. Ask your FailproofAI Cloud admin. Hooks fall back to regex and ask " +
396
+ "again at most once a minute."
397
+ );
398
+ }
399
+ // `request_rejected`: TypeSafe refused this request itself, typically dense
400
+ // text over Jev's token budget. The same call gets the same answer.
401
+ if (code === "http-422") return "Jev refused this call's request, usually because it held dense text (base64, hex, minified code) over Jev's token budget. That call falls back to regex every time; this is not an outage.";
402
+ if (code === "model-mismatch") return "FailproofAI Cloud answered with a model outside the Jev 1.13 family, so hooks would fall back to regex. This is the server's to fix.";
403
+ // The generic advice for these names `--base-url`, which this route refuses:
404
+ // its endpoint is the Cloud this machine connected to, changed only by
405
+ // reconnecting.
406
+ if (/^http-3(?:\d\d|xx)$/.test(code)) {
407
+ return "FailproofAI Cloud answered with a redirect, and Jev requests never follow one. The URL this machine connected to is probably not the one that serves the API (a sign-in page, or an old address). Reconnect with the right one: failproofai config --token <key> --url <url>";
408
+ }
409
+ if (code === "network") {
410
+ return "FailproofAI Cloud could not be reached. Check your network; if the Cloud's address has changed, reconnect: failproofai config --token <key> --url <url>";
411
+ }
412
+ if (code === "config") return "The config is not usable. Rebuild it from this machine's FailproofAI Cloud connection: failproofai jev setup --provider failproofai";
413
+ return null;
414
+ }
415
+
241
416
  /** What the person should do about a failed request, by cause. */
242
- function remedy(code: string): string {
417
+ function remedy(code: string, provider?: JevProviderKind, message?: string): string {
418
+ if (provider === JEV_CLOUD_PROVIDER) {
419
+ const cloud = cloudRemedy(code, message);
420
+ if (cloud) return cloud;
421
+ }
243
422
  if (code === "http-401" || code === "http-403") return "The provider refused the key. Re-run `failproofai jev setup` with the right one.";
244
423
  if (code === "out-of-credits") return "The account is out of credits (HTTP 402). Top it up with the provider; until then hooks fall back to regex.";
424
+ // The message from `httpFailureMessage` has already said what a 404 there
425
+ // means; this is the part that is an instruction rather than a diagnosis.
426
+ if (code === "http-404") {
427
+ return "Set the base URL to the provider's version root, without the endpoint path: `failproofai jev setup --base-url <base>`. `failproofai jev models` says what a base serves.";
428
+ }
245
429
  if (code === "http-429") return "The provider rate-limited this key. Hooks fall back to regex whenever that happens.";
246
430
  if (code.startsWith("http-5")) return "The provider had a server error. Hooks fall back to regex whenever that happens; try again shortly.";
247
431
  if (code === "timeout") return "No answer in time. Check the endpoint and your network.";
@@ -323,28 +507,6 @@ function movedHost(provider: JevProviderKind, before: unknown, after: unknown):
323
507
  return { from, to };
324
508
  }
325
509
 
326
- /**
327
- * The provider each known host IS, so `failproofai jev --url <url>` needs no
328
- * `--provider`. Exact hosts only: a neighbouring name (`eu.api.typesafe.ai`, a
329
- * corporate proxy) is `custom`, which reaches the same TypeSafe-compatible API
330
- * at the URL given, and `--provider` is there to say otherwise.
331
- */
332
- const PROVIDER_BY_HOST: Readonly<Record<string, JevProviderKind>> = {
333
- "api.typesafe.ai": "typesafe",
334
- "openrouter.ai": "openrouter",
335
- "ai-gateway.vercel.sh": "vercel",
336
- "api.cloudflare.com": "cloudflare",
337
- };
338
-
339
- /** The provider a validated URL names, or `custom` for a host that is nobody's. */
340
- function providerForUrl(url: string): JevProviderKind {
341
- try {
342
- return PROVIDER_BY_HOST[new URL(url).hostname.toLowerCase()] ?? "custom";
343
- } catch {
344
- return "custom";
345
- }
346
- }
347
-
348
510
  /**
349
511
  * The account id a Cloudflare URL already carries, or null.
350
512
  *
@@ -360,7 +522,7 @@ function providerForUrl(url: string): JevProviderKind {
360
522
  export function accountIdFromUrl(url: string): string | null {
361
523
  try {
362
524
  const parsed = new URL(url);
363
- if (PROVIDER_BY_HOST[parsed.hostname.toLowerCase()] !== "cloudflare") return null;
525
+ if (providerForUrl(url) !== "cloudflare") return null;
364
526
  const m = /\/accounts\/([0-9a-f]{32})(?:\/|$)/i.exec(parsed.pathname);
365
527
  return m ? m[1].toLowerCase() : null;
366
528
  } catch {
@@ -402,6 +564,70 @@ function offProviderBase(cfg: JevConfig, endpoint: string): { endpoint: string;
402
564
  }
403
565
  }
404
566
 
567
+ /**
568
+ * The refusal for a URL that is an endpoint where a base belongs.
569
+ *
570
+ * Both URLs go through `displayEndpoint`, which replaces a query string with
571
+ * `?…`: a proxy base may legitimately carry `?api-version=` — or a token — and a
572
+ * refusal is not a reason to print one. That does make the suggested command a
573
+ * shape rather than something to paste, which is the right trade: the person has
574
+ * their own URL, and the point of the line is which segment to drop.
575
+ */
576
+ function endpointAsBaseRefusal(flag: string, given: string, found: EndpointGivenAsBase): string[] {
577
+ return [
578
+ `Not saved: ${flag} names an endpoint, not an API base — its path ends in ${found.suffix}.`,
579
+ endpointAsBaseReason(given),
580
+ "Give the base it sits under:",
581
+ ` failproofai jev ${flag} ${displayEndpoint(found.base)}`,
582
+ "",
583
+ "Nothing was written.",
584
+ ];
585
+ }
586
+
587
+ /** At most this many names in one refusal or one rendered list; the rest are counted. */
588
+ const MAX_SHOWN_MODELS = 20;
589
+
590
+ /** Model names for one line, bounded — the list came from a provider and may be long. */
591
+ function shownModels(models: string[]): string {
592
+ if (models.length <= MAX_SHOWN_MODELS) return models.join(", ");
593
+ return `${models.slice(0, MAX_SHOWN_MODELS).join(", ")} … and ${models.length - MAX_SHOWN_MODELS} more`;
594
+ }
595
+
596
+ /**
597
+ * The list this config's endpoint serves, when it both could be read AND is
598
+ * authoritative about `<base>/systemone` AND does not carry the configured model
599
+ * — i.e. the one case that is worth refusing. Null in every other case, so a
600
+ * caller's "carry on unchanged" path is the default rather than the exception.
601
+ *
602
+ * `apiKey` is passed separately because a `--key-from-env` config in a shell with
603
+ * no variable set holds a stand-in, which must not be sent anywhere; the public
604
+ * lists (Vercel's, OpenRouter's) answer without a key anyway.
605
+ */
606
+ async function modelAbsentFromList(
607
+ cfg: JevConfig,
608
+ model: string,
609
+ apiKey: string | null,
610
+ read: JevModelListReader,
611
+ ): Promise<{ list: JevModelListRead; url: string } | null> {
612
+ let url: string | null;
613
+ try {
614
+ url = jevModelsUrl(cfg);
615
+ } catch {
616
+ return null;
617
+ }
618
+ if (url === null) return null;
619
+ let list: JevModelListResult;
620
+ try {
621
+ list = await read(url, apiKey);
622
+ } catch {
623
+ // A reader that throws is a reader that read nothing, which is the case this
624
+ // whole check is required to survive.
625
+ return null;
626
+ }
627
+ if (!list.ok || !listDescribesSystemOne(list) || modelListHasModel(list, model)) return null;
628
+ return { list, url };
629
+ }
630
+
405
631
  async function readAllStdin(): Promise<string> {
406
632
  const chunks: Buffer[] = [];
407
633
  for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
@@ -413,7 +639,20 @@ async function maskedPrompt(): Promise<string | null> {
413
639
  return promptText({ message: "Jev API key", mask: true, validate: (v) => validateApiKey(v) });
414
640
  }
415
641
 
642
+ /** What a run that put the key in argv ends with, saved or refused: the key is in history either way. */
643
+ const TOKEN_HISTORY_WARNING = [
644
+ TOKEN_ON_ARGV,
645
+ "Prefer piping the key in — `failproofai jev --url <url> --key-stdin < key-file` — and rotate this one if it matters.",
646
+ ];
647
+
416
648
  async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
649
+ const r = await setupRun(argv, deps, opts);
650
+ // A save says it in its own place; every refusal says it here.
651
+ const tokenGiven = argv.some((a, i) => a.startsWith("--token=") || (a === "--token" && i + 1 < argv.length));
652
+ return r.exitCode === 0 || !tokenGiven ? r : { ...r, lines: [...r.lines, "", ...TOKEN_HISTORY_WARNING] };
653
+ }
654
+
655
+ async function setupRun(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
417
656
  const parsed = parseFlags(argv, new Set([...VALUE_FLAGS, "--key-stdin", "--key-from-env"]));
418
657
  if (typeof parsed === "string") return fail([parsed, "", ...JEV_USAGE]);
419
658
  if (parsed.positionals.length > 0) return fail([STRAY_ARGUMENT, "", ...JEV_USAGE]);
@@ -450,6 +689,22 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
450
689
  ]);
451
690
  }
452
691
 
692
+ // FailproofAI Cloud has a setup of its own: its endpoint and key come from the
693
+ // machine's connection, so none of the flags below that choose them apply.
694
+ // Reached by naming it, or by re-running setup (a mode switch, say) over a
695
+ // Cloud file without naming another provider or a URL — never by a URL: a
696
+ // `--url` is a BYOK endpoint whatever host it names (`PROVIDER_BY_HOST`
697
+ // deliberately has no Cloud entry).
698
+ if (
699
+ namedProvider === JEV_CLOUD_PROVIDER ||
700
+ (namedProvider === undefined &&
701
+ !values.has("--url") &&
702
+ !values.has("--base-url") &&
703
+ readJevConfigFileForUpdate()?.raw.provider === JEV_CLOUD_PROVIDER)
704
+ ) {
705
+ return cloudSetup(values, bools, opts);
706
+ }
707
+
453
708
  // `--url` is `--base-url` that also picks the provider. It is validated here,
454
709
  // by the loader's own `validateBaseUrl`, so a URL is refused in the same
455
710
  // words whichever way it arrived — and so the host below is a parsed host,
@@ -467,24 +722,15 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
467
722
  // An explicit provider wins — except where it disagrees with the host about
468
723
  // which gateway this is, which is a disagreement about where the key goes.
469
724
  // `custom` is not a disagreement: it is the "treat this URL as itself" ask.
470
- if (explicit !== null && explicit !== "custom" && urlProvider.kind !== "custom" && explicit !== urlProvider.kind) {
725
+ // The rule is `providerHostConflict`'s, shared with `--base-url` below and
726
+ // the dashboard's save; only the advice here is about `--url`.
727
+ const conflict = explicit === null ? null : providerHostConflict(explicit, normalized);
728
+ if (conflict !== null) {
471
729
  return fail([
472
- `--provider ${explicit} and --url disagree: ${urlProvider.host} is ${urlProvider.kind}'s endpoint, not ${explicit}'s.`,
473
- "Drop --provider to take the provider from the URL, or give the URL that provider's own endpoint.",
474
- "",
475
- "Nothing was written.",
476
- ]);
477
- }
478
- // The one host where "treat this URL as itself" cannot be honoured. A
479
- // `custom` route POSTs the TypeSafe-native body to `<url>/systemone` and
480
- // reads a bare answer back; Workers AI answers at `/accounts/<id>/ai/run`
481
- // and wraps its answer in `result`, which is why it has a provider of its
482
- // own. Saved as `custom` it would ask for the `--account-id` it then had
483
- // nowhere to put, and every call would fall back to regex.
484
- if (explicit === "custom" && urlProvider.kind === "cloudflare") {
485
- return fail([
486
- `Not saved: ${urlProvider.host} is Cloudflare Workers AI, which provider custom cannot reach — its endpoint is per-account and its answers are wrapped, and a custom endpoint is asked in TypeSafe's own shape at <url>/systemone.`,
487
- "Use the provider that speaks it: --provider cloudflare --account-id <32 hex characters> — or drop --provider, since the URL already says cloudflare.",
730
+ explicit === "custom" ? `Not saved: ${conflict}.` : `--provider ${explicit} and --url disagree: ${conflict}.`,
731
+ explicit === "custom"
732
+ ? "Use the provider that speaks it: --provider cloudflare --account-id <32 hex characters> — or drop --provider, since the URL already says cloudflare."
733
+ : "Drop --provider to take the provider from the URL, or give the URL that provider's own endpoint.",
488
734
  "",
489
735
  "Nothing was written.",
490
736
  ]);
@@ -526,6 +772,21 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
526
772
  values.delete("--url");
527
773
  }
528
774
 
775
+ // An endpoint given where a base belongs, whichever flag carried it — `--url`
776
+ // has already become `--base-url` above, so the flag NAMED here is the one the
777
+ // person typed. Before the key is asked for, and before anything is written:
778
+ // the fix is one segment, and nothing should be stored or prompted for first.
779
+ const baseArg = values.get("--base-url");
780
+ if (baseArg !== undefined && baseArg !== "default") {
781
+ const checkedBase = validateBaseUrl(baseArg);
782
+ // An unusable URL is refused by validation below, in its own words; this
783
+ // check only has something to say about one that parses.
784
+ if (checkedBase.ok) {
785
+ const asEndpoint = endpointGivenAsBase(checkedBase.value);
786
+ if (asEndpoint) return fail(endpointAsBaseRefusal(urlArg !== undefined ? "--url" : "--base-url", checkedBase.value, asEndpoint));
787
+ }
788
+ }
789
+
529
790
  const existingFile = readJevConfigFileForUpdate();
530
791
  const existing = existingFile?.raw ?? null;
531
792
  const providerArg = values.get("--provider");
@@ -542,6 +803,22 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
542
803
  return fail(["Unknown provider (not repeated here, in case it is a key).", `Providers: ${JEV_PROVIDER_KINDS.join(", ")}`]);
543
804
  }
544
805
  const kind = provider as JevProviderKind;
806
+ // The same rule for a `--base-url` typed as such (a `--url` was checked above,
807
+ // with advice about `--url`), so the flag and the dashboard cannot save a
808
+ // pair `--url` refuses.
809
+ const typedBase = urlArg === undefined ? values.get("--base-url") : undefined;
810
+ const checkedTyped = typedBase === undefined || typedBase === "default" ? null : validateBaseUrl(typedBase);
811
+ const hostConflict = checkedTyped?.ok ? providerHostConflict(kind, checkedTyped.value) : null;
812
+ if (checkedTyped?.ok && hostConflict !== null) {
813
+ return fail([
814
+ `Not saved: ${hostConflict}.`,
815
+ kind === "custom"
816
+ ? "Use the provider that speaks it: --provider cloudflare --account-id <32 hex characters>."
817
+ : `Give ${kind}'s own endpoint, or the provider this URL belongs to: --provider ${providerForUrl(checkedTyped.value)}.`,
818
+ "",
819
+ "Nothing was written.",
820
+ ]);
821
+ }
545
822
  const sameProvider = existing !== null && existing.provider === provider;
546
823
 
547
824
  // Same provider: update in place, keeping the key and every field not named
@@ -718,6 +995,42 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
718
995
  if (!checked.ok) return fail([`Not saved: ${checked.problem}.`, "", "Nothing was written."]);
719
996
  const cfg = checked.value;
720
997
 
998
+ // The model, against what the endpoint says it serves. This is the second half
999
+ // of the LiteLLM-proxy failure: the URL was fixed and the default model still
1000
+ // did not exist there, which nothing said until `jev test` returned a 404 and
1001
+ // the person curled the endpoint by hand.
1002
+ //
1003
+ // Never a hard requirement: a list that cannot be read (no key yet, a proxy
1004
+ // that serves none, Cloudflare, an unreachable host, a shape nobody knows) and
1005
+ // a list that is not authoritative about `/systemone` both fall through to the
1006
+ // write, so this can only ever turn a silent misconfiguration into a refusal —
1007
+ // it can never turn a working setup into a failing one.
1008
+ const routeForCheck = ((): ReturnType<typeof jevRoute> | null => {
1009
+ try {
1010
+ return jevRoute(cfg);
1011
+ } catch {
1012
+ return null;
1013
+ }
1014
+ })();
1015
+ if (routeForCheck !== null) {
1016
+ // The stand-in is not a key and must not be sent; the public lists answer
1017
+ // without one, so the read is still worth making.
1018
+ const listKey = keyFromEnv && !envKey ? null : cfg.apiKey;
1019
+ const absent = await modelAbsentFromList(cfg, routeForCheck.model, listKey, deps.readModelList ?? liveModelListReader);
1020
+ if (absent) {
1021
+ return fail([
1022
+ `Not saved: ${displayEndpoint(absent.url)} does not list ${routeForCheck.model}, so every request would fail there.`,
1023
+ ` it serves: ${shownModels(absent.list.models)}`,
1024
+ routeForCheck.modelIsDefault
1025
+ ? `${routeForCheck.model} is this build's default for provider ${provider}, and this endpoint names Jev differently — so --model is not optional here. Add it to the command you just ran:`
1026
+ : "Pick one of the names above:",
1027
+ ` --model ${absent.list.models[0]}`,
1028
+ "",
1029
+ "Nothing was written.",
1030
+ ]);
1031
+ }
1032
+ }
1033
+
721
1034
  const path = jevConfigPath();
722
1035
  try {
723
1036
  writeJsonAtomically(path, next, { mode: 0o600, dirMode: 0o700 });
@@ -793,17 +1106,223 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
793
1106
  // command line it came on is not — it is in this shell's history file and
794
1107
  // was readable from /proc by anything running as this user while the
795
1108
  // process lived.
796
- tokenOnCommandLine
797
- ? warning(
798
- [
799
- "--token was on the command line: your shell history has it, and while this command ran any process of yours could read it from the process list.",
800
- "Prefer piping the key in — `failproofai jev --url <url> --key-stdin < key-file` — and rotate this one if it matters.",
801
- ],
802
- opts,
803
- )
804
- : null,
1109
+ tokenOnCommandLine ? warning(TOKEN_HISTORY_WARNING, opts) : null,
805
1110
  note("Hooks read this file on every tool call — no restart. Without it they run the regex policies exactly as before.", opts),
806
- nextStep("failproofai jev test", "Check it with one live request:", opts),
1111
+ // Switched off, `jev test` only answers "not run — switched off": a next
1112
+ // step that leads nowhere is worse than none.
1113
+ (cfg.mode ?? DEFAULT_JEV_MODE) === "off" ? null : nextStep("failproofai jev test", "Check it with one live request:", opts),
1114
+ ),
1115
+ );
1116
+ }
1117
+
1118
+ // ── setup: FailproofAI Cloud ─────────────────────────────────────────────────
1119
+
1120
+ /** How `inspectJevConfig` opens a refusal that is about credentials.json. */
1121
+ const CLOUD_CREDENTIAL_REFUSED = "the FailproofAI Cloud credential was refused";
1122
+
1123
+ /**
1124
+ * The next step for a refused config: one ladder, so `status` and `test` never
1125
+ * disagree about the same inspection.
1126
+ */
1127
+ function refusedNextStep(inspection: Extract<JevConfigInspection, { status: "refused" }>, cloudFile: boolean): { cmd: string; lead: string } {
1128
+ // The Cloud route's key file is credentials.json, and a refusal can be about
1129
+ // THAT file: the key it holds was put there by `config --token`, not by
1130
+ // anything `jev setup` could re-ask for.
1131
+ if (cloudFile && inspection.problem.startsWith(CLOUD_CREDENTIAL_REFUSED)) {
1132
+ if (inspection.reason !== "too-open") {
1133
+ return { cmd: "failproofai config --token <key>", lead: "credentials.json could not be read. Reconnect, which rewrites it at 0600:" };
1134
+ }
1135
+ const m = inspection.credentialsMode ?? 0;
1136
+ const risks = [
1137
+ ...((m & 0o044) !== 0 ? ["other users can read it, and with it spend this machine's FailproofAI Cloud key"] : []),
1138
+ ...((m & 0o022) !== 0 ? ["other users could change it"] : []),
1139
+ ];
1140
+ const risk = risks.length > 0 ? risks.join(", and ") : "other users can write where it lives, so they could put their own in its place";
1141
+ return {
1142
+ cmd: inspection.fix ?? "failproofai config --token <key>",
1143
+ lead: `credentials.json holds the key Jev sends to FailproofAI Cloud, and ${risk}. Make it owner-only (or reconnect, which rewrites it at 0600):`,
1144
+ };
1145
+ }
1146
+ if (inspection.reason === "too-open") {
1147
+ // Read bits alone expose the key in it; only write bits (or a loose
1148
+ // directory, where the file's own mode is owner-only) let others change it.
1149
+ const m = inspection.mode ?? 0;
1150
+ const readOnly = (m & 0o044) !== 0 && (m & 0o022) === 0;
1151
+ return {
1152
+ // Either the file or the directory it sits in; `fix` says which.
1153
+ cmd: inspection.fix ?? `chmod 600 ${inspection.path}`,
1154
+ lead: readOnly
1155
+ ? inspection.keyless
1156
+ ? "Make it owner-only:"
1157
+ : "Other users can read this file, and with it any key it holds. Make it owner-only (and rotate the key if it matters):"
1158
+ : cloudFile
1159
+ ? "Other users could change this file, so check that endpoint is one you chose. Then make it owner-only (or rebuild it from this machine's connection: `failproofai jev setup --provider failproofai`):"
1160
+ : "Other users could change this file, so check that endpoint is one you chose. Then make it owner-only (or re-run `failproofai jev setup`, which asks for the key again unless the endpoint is the provider's own):",
1161
+ };
1162
+ }
1163
+ if (cloudFile) return { cmd: "failproofai jev setup --provider failproofai", lead: "Rewrite it from this machine's FailproofAI Cloud connection:" };
1164
+ return { cmd: "failproofai jev setup --provider <kind> --key-stdin", lead: "Write a valid one:" };
1165
+ }
1166
+
1167
+ /**
1168
+ * `jev setup --provider failproofai`, and any setup run over a Cloud file.
1169
+ *
1170
+ * The Cloud route has nothing to choose but its mode and timeout: the endpoint
1171
+ * is `<Cloud base>/enforcement/v1/jev` on the origin the machine connected to,
1172
+ * the key is the connection's own (`credentials.json`, never this file) and the
1173
+ * model is pinned by the server. So every flag that would choose one of those is
1174
+ * refused — naming it would be asking for something this route cannot honour —
1175
+ * and the file is rebuilt from the connection. An `apiKey` a hand-edited file
1176
+ * carries is dropped, which is also what makes such a file valid again.
1177
+ *
1178
+ * A mode or timeout change on an existing Cloud file does not need the machine
1179
+ * to be connected: it changes nothing about where a key goes. Creating one,
1180
+ * or rebuilding its endpoint, does.
1181
+ */
1182
+ async function cloudSetup(values: Map<string, string>, bools: Set<string>, opts: RenderOpts): Promise<JevCliResult> {
1183
+ const refusedFlag = (["--token", "--url", "--base-url", "--account-id", "--model"] as const).find((f) => values.has(f)) ??
1184
+ (["--key-stdin", "--key-from-env"] as const).find((f) => bools.has(f));
1185
+ if (refusedFlag !== undefined) {
1186
+ const why =
1187
+ refusedFlag === "--model"
1188
+ ? `FailproofAI Cloud runs ${JEV_PROVIDER_DEFAULTS.failproofai.model}, pinned on the server; there is no model to choose.`
1189
+ : refusedFlag === "--account-id"
1190
+ ? "--account-id is Cloudflare's; FailproofAI Cloud has no use for one."
1191
+ : refusedFlag === "--url" || refusedFlag === "--base-url"
1192
+ ? "FailproofAI Cloud's endpoint is the one this machine connected to; to change it, reconnect: failproofai config --token <key> --url <url>"
1193
+ : "FailproofAI Cloud's key is this machine's connection key, kept in credentials.json and never in jev.json; to change it, reconnect: failproofai config --token <key>";
1194
+ return fail([`Not saved: ${refusedFlag} does not apply to provider failproofai. ${why}`, "", "Nothing was written."]);
1195
+ }
1196
+
1197
+ const existing = readJevConfigFileForUpdate();
1198
+ const sameProvider = existing?.raw.provider === JEV_CLOUD_PROVIDER;
1199
+ const credential = readJevCloudCredential();
1200
+ if (credential.status === "refused") {
1201
+ return fail([`Not saved: ${credential.problem}.`, "", "Nothing was written."]);
1202
+ }
1203
+
1204
+ const next: Record<string, unknown> = sameProvider ? { ...existing!.raw } : { provider: JEV_CLOUD_PROVIDER };
1205
+ // Never a key in a Cloud file, and nothing only another provider reads.
1206
+ delete next.apiKey;
1207
+ delete next.accountId;
1208
+ if (!sameProvider && existing?.raw.timeoutMs !== undefined) next.timeoutMs = existing.raw.timeoutMs;
1209
+ // Shadow unless told otherwise, as `config --token` starts it: Jev on a new
1210
+ // route is logged before it is allowed to clear anything. A mode already in a
1211
+ // Cloud file is kept.
1212
+ const mode = values.get("--mode") ?? (sameProvider && typeof next.mode === "string" ? next.mode : "shadow");
1213
+ next.mode = mode;
1214
+ if (values.has("--timeout-ms")) {
1215
+ const rawTimeout = values.get("--timeout-ms") as string;
1216
+ const n = Number(rawTimeout);
1217
+ if (!/^\d+$/.test(rawTimeout) || !Number.isSafeInteger(n)) return fail(["Could not read --timeout-ms. Give a whole number of milliseconds."]);
1218
+ next.timeoutMs = n;
1219
+ }
1220
+
1221
+ let keySource: JevCloudCredential | null = null;
1222
+ if (credential.status === "ok") {
1223
+ keySource = credential.credential;
1224
+ // The endpoint, from the connection: kept when it is already on the
1225
+ // credential's origin, else rebuilt — under the policy connection's base
1226
+ // when that is on the same origin, so a path prefix survives.
1227
+ const onOrigin = (u: unknown) => {
1228
+ try {
1229
+ return typeof u === "string" && new URL(u).origin === new URL(credential.credential.url).origin;
1230
+ } catch {
1231
+ return false;
1232
+ }
1233
+ };
1234
+ if (!onOrigin(next.baseUrl)) {
1235
+ const policyBase = readCredentials().cloud?.url;
1236
+ next.baseUrl = jevCloudBaseUrl(onOrigin(policyBase) ? (policyBase as string) : credential.credential.url);
1237
+ }
1238
+ } else if (!sameProvider || typeof next.baseUrl !== "string") {
1239
+ return fail([
1240
+ ...(credential.connected
1241
+ ? [
1242
+ // Its key may well carry jev:evaluate: an older failproofai connected
1243
+ // without storing a Jev key, and the same key fixes that.
1244
+ "Not saved: this machine is connected to FailproofAI Cloud, but no Jev key is stored for that connection.",
1245
+ "Reconnect with this machine's key to re-check what it carries (an older failproofai connected without storing its Jev key; " +
1246
+ "a key without jev:evaluate needs the \"machine\" preset on the dashboard's Keys page):",
1247
+ ]
1248
+ : [
1249
+ "Not saved: this machine is not connected to FailproofAI Cloud with a key that carries jev:evaluate.",
1250
+ "Connect it with one that does (the \"machine\" preset) — that also turns Jev on, in shadow mode, when there is no jev.json yet:",
1251
+ ]),
1252
+ " failproofai config --token <key>",
1253
+ "",
1254
+ "Nothing was written.",
1255
+ ]);
1256
+ }
1257
+
1258
+ // The loader's own rules, with the connection's key or — for a mode switch
1259
+ // on a machine that is not connected right now — a stand-in on the file's own
1260
+ // origin, which is never written or sent.
1261
+ let standInOrigin = "";
1262
+ try {
1263
+ standInOrigin = new URL(String(next.baseUrl)).origin;
1264
+ } catch {
1265
+ standInOrigin = "";
1266
+ }
1267
+ const checked = validateJevConfig(next, null, keySource ?? { url: standInOrigin, key: ENV_KEY_STAND_IN });
1268
+ if (!checked.ok) return fail([`Not saved: ${checked.problem}.`, "", "Nothing was written."]);
1269
+
1270
+ const path = jevConfigPath();
1271
+ try {
1272
+ writeJsonAtomically(path, next, { mode: 0o600, dirMode: 0o700 });
1273
+ } catch (err) {
1274
+ return fail([`Could not write ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}.`]);
1275
+ }
1276
+ if (process.platform !== "win32") {
1277
+ try {
1278
+ const dir = dirname(path);
1279
+ const before = statSync(dir).mode & 0o777;
1280
+ if ((before & 0o022) !== 0) chmodSync(dir, before & ~0o022);
1281
+ } catch {
1282
+ // Not fatal: the loader reports a directory it will not read from.
1283
+ }
1284
+ }
1285
+ let fileMode: number | null = null;
1286
+ try {
1287
+ fileMode = statSync(path).mode & 0o777;
1288
+ } catch {
1289
+ // Reported as unknown below.
1290
+ }
1291
+
1292
+ const cfg = checked.value;
1293
+ const route = jevRoute(cfg);
1294
+ const shownMode = cfg.mode ?? DEFAULT_JEV_MODE;
1295
+ return ok(
1296
+ stack(
1297
+ title("failproofai jev setup", `saved · FailproofAI Cloud · ${shownMode}`, opts),
1298
+ rows(
1299
+ [
1300
+ ["provider", providerLabel(JEV_CLOUD_PROVIDER)],
1301
+ ["endpoint", shownEndpoint(JEV_CLOUD_PROVIDER, route.endpoint)],
1302
+ ["model", `${route.model} (pinned by FailproofAI Cloud)`],
1303
+ ["mode", modeLine(shownMode)],
1304
+ ["timeout", `${cfg.timeoutMs} ms`],
1305
+ // Which of the three states the connection is in — the same row
1306
+ // `status` shows. "Not connected" on a machine that IS connected, with
1307
+ // a key that lacks Jev, sent its owner to reconnect with that same key.
1308
+ ["key", keySource ? CLOUD_KEY_SOURCE : cloudKeyRow()],
1309
+ ["config", path],
1310
+ ["permissions", permissions(fileMode)],
1311
+ ],
1312
+ opts,
1313
+ ),
1314
+ note("Hooks read this file on every tool call — no restart. Calls are charged to your FailproofAI Cloud org's plan.", opts),
1315
+ // With no usable key, `jev test` only answers "not run": the step that
1316
+ // helps is the connection. Switched off, there is no step to take.
1317
+ shownMode === "off"
1318
+ ? null
1319
+ : keySource
1320
+ ? nextStep("failproofai jev test", "Check it with one live request:", opts)
1321
+ : nextStep(
1322
+ "failproofai config --token <key>",
1323
+ "Jev stays off until this machine is connected with a key that carries jev:evaluate (the \"machine\" preset on the dashboard's Keys page):",
1324
+ opts,
1325
+ ),
807
1326
  ),
808
1327
  );
809
1328
  }
@@ -824,15 +1343,30 @@ async function setup(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promis
824
1343
  */
825
1344
  function routeForRouting(routing: Omit<JevConfig, "apiKey">): ReturnType<typeof jevRoute> | null {
826
1345
  try {
827
- return jevRoute({ ...routing, apiKey: ENV_KEY_STAND_IN });
1346
+ return jevRoute({ ...routing, apiKey: ENV_KEY_STAND_IN, ...displayOnlyCloudOrigin(routing) });
828
1347
  } catch {
829
1348
  return null;
830
1349
  }
831
1350
  }
832
1351
 
1352
+ /**
1353
+ * For DISPLAY only, like the stand-in key beside it: a FailproofAI Cloud route
1354
+ * is built only against the origin of the credential it was validated with
1355
+ * (`validateLoadedJevConfig`), and a config shown here has no credential behind
1356
+ * it, so the file's own origin stands in. Nothing built with it is ever sent.
1357
+ */
1358
+ function displayOnlyCloudOrigin(routing: { provider?: unknown; baseUrl?: unknown }): { credentialOrigin?: string } {
1359
+ if (routing.provider !== JEV_CLOUD_PROVIDER || typeof routing.baseUrl !== "string") return {};
1360
+ try {
1361
+ return { credentialOrigin: new URL(routing.baseUrl).origin };
1362
+ } catch {
1363
+ return {};
1364
+ }
1365
+ }
1366
+
833
1367
  function namedEndpoint(raw: Record<string, unknown> | null): string | null {
834
1368
  if (!raw || typeof raw.provider !== "string" || !(JEV_PROVIDER_KINDS as readonly string[]).includes(raw.provider)) return null;
835
- const routing: Record<string, unknown> = { provider: raw.provider, apiKey: ENV_KEY_STAND_IN, mode: "shadow" };
1369
+ const routing: Record<string, unknown> = { provider: raw.provider, apiKey: ENV_KEY_STAND_IN, mode: "shadow", ...displayOnlyCloudOrigin(raw) };
836
1370
  if (raw.baseUrl !== undefined) routing.baseUrl = raw.baseUrl;
837
1371
  if (raw.accountId !== undefined) routing.accountId = raw.accountId;
838
1372
  try {
@@ -879,8 +1413,23 @@ async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
879
1413
 
880
1414
  if (asJson) {
881
1415
  const base: Record<string, unknown> = { path: inspection.path, status: inspection.status, legacyOverride: legacy, stats };
1416
+ // The absent text says whether this machine's key already carries Jev.
1417
+ if (inspection.status === "absent") Object.assign(base, cloudConnection());
882
1418
  if (inspection.status === "refused") {
883
1419
  Object.assign(base, { reason: inspection.reason, problem: inspection.problem, permissions: octal(inspection.mode) });
1420
+ if (inspection.fix) base.fix = inspection.fix;
1421
+ // `permissions` is jev.json's; a refusal about credentials.json says its own.
1422
+ if (inspection.credentialsMode !== undefined) base.credentialsPermissions = octal(inspection.credentialsMode);
1423
+ // Known whatever the file says, or whether it parses at all.
1424
+ Object.assign(base, cloudConnection());
1425
+ if (readJevConfigFileForUpdate()?.raw?.provider === JEV_CLOUD_PROVIDER) {
1426
+ Object.assign(base, {
1427
+ provider: JEV_CLOUD_PROVIDER,
1428
+ providerLabel: providerLabel(JEV_CLOUD_PROVIDER),
1429
+ keySource: "cloud",
1430
+ keySourceLabel: CLOUD_KEY_SOURCE,
1431
+ });
1432
+ }
884
1433
  if (inspection.reason === "too-open") {
885
1434
  const named = namedEndpoint(readJevConfigFileForUpdate()?.raw ?? null);
886
1435
  if (named) base.endpoint = named;
@@ -894,6 +1443,7 @@ async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
894
1443
  Object.assign(base, {
895
1444
  permissions: octal(inspection.mode),
896
1445
  provider: inspection.routing.provider,
1446
+ providerLabel: providerLabel(inspection.routing.provider),
897
1447
  ...(route ? { endpoint: displayEndpoint(route.endpoint), model: route.model, modelIsDefault: route.modelIsDefault } : {}),
898
1448
  mode: inspection.routing.mode,
899
1449
  timeoutMs: inspection.routing.timeoutMs,
@@ -903,18 +1453,42 @@ async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
903
1453
  problem: inspection.problem,
904
1454
  });
905
1455
  }
1456
+ if (inspection.status === "off" || inspection.status === "not-connected" || inspection.status === "key-lacks-jev") {
1457
+ // Configured and deliberately (off) or circumstantially (not connected,
1458
+ // or connected with a key that has no Jev) not running. The routing is
1459
+ // reported exactly as for `ok`, so a provisioning check can tell any of
1460
+ // them from a file it should rewrite.
1461
+ const r = inspection.routing;
1462
+ const route = routeForRouting(r);
1463
+ const cloud = r.provider === JEV_CLOUD_PROVIDER;
1464
+ Object.assign(base, {
1465
+ permissions: octal(inspection.mode),
1466
+ provider: r.provider,
1467
+ providerLabel: providerLabel(r.provider),
1468
+ ...(route ? { endpoint: shownEndpoint(r.provider, route.endpoint), model: route.model, modelIsDefault: route.modelIsDefault } : {}),
1469
+ mode: r.mode,
1470
+ timeoutMs: r.timeoutMs,
1471
+ // Read from the credentials file rather than inferred from the status:
1472
+ // `off` is decided before any key is looked for, so it says nothing
1473
+ // about the connection either way.
1474
+ ...(cloud ? { keySource: "cloud", keySourceLabel: CLOUD_KEY_SOURCE, ...cloudConnection() } : {}),
1475
+ ...(inspection.status === "off" ? { reason: "switched-off" } : { reason: inspection.status, problem: inspection.problem }),
1476
+ });
1477
+ }
906
1478
  if (inspection.status === "ok") {
907
1479
  const { config: cfg } = inspection;
908
1480
  const route = jevRoute(cfg);
909
1481
  Object.assign(base, {
910
1482
  permissions: octal(inspection.mode),
911
1483
  provider: cfg.provider,
912
- endpoint: displayEndpoint(route.endpoint),
1484
+ providerLabel: providerLabel(cfg.provider),
1485
+ endpoint: shownEndpoint(cfg.provider, route.endpoint),
913
1486
  model: route.model,
914
1487
  modelIsDefault: route.modelIsDefault,
915
1488
  mode: cfg.mode,
916
1489
  timeoutMs: cfg.timeoutMs,
917
1490
  keySource: inspection.keySource,
1491
+ ...(inspection.keySource === "cloud" ? { keySourceLabel: CLOUD_KEY_SOURCE, cloudConnected: true, keyCarriesJev: true } : {}),
918
1492
  // How much of this machine's policy set Jev is allowed to clear, and
919
1493
  // why it is none when it is none. A provisioning check that turns Jev
920
1494
  // on has no other way to find out that the half it turned on cannot
@@ -957,6 +1531,12 @@ async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
957
1531
  },
958
1532
  opts,
959
1533
  ),
1534
+ note(
1535
+ cloudCredentialPresent()
1536
+ ? "Or through FailproofAI Cloud, on your org's plan — this machine's key already carries jev:evaluate: failproofai jev setup --provider failproofai"
1537
+ : "Or through FailproofAI Cloud, on your org's plan, with no key of your own: connect with a key that carries jev:evaluate (the \"machine\" preset) — failproofai config --token <key>",
1538
+ opts,
1539
+ ),
960
1540
  jevStatsLines(stats, opts),
961
1541
  ),
962
1542
  );
@@ -999,24 +1579,116 @@ async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
999
1579
  );
1000
1580
  }
1001
1581
 
1582
+ if (inspection.status === "off") {
1583
+ // Switched off, not broken and not absent: the file is kept so switching
1584
+ // back on needs no key and no endpoint re-typed. Said as "off" first,
1585
+ // because that is the whole answer to "is Jev running?".
1586
+ const r = inspection.routing;
1587
+ const route = routeForRouting(r);
1588
+ return ok(
1589
+ stack(
1590
+ title("failproofai jev status", "off (switched off)", opts),
1591
+ note(`Jev is switched off in ${inspection.path}: it is not asked about any tool call, and hooks run the regex policies exactly as before.`, opts),
1592
+ rows(
1593
+ [
1594
+ ["provider", providerLabel(r.provider)],
1595
+ ...((route ? [["endpoint", shownEndpoint(r.provider, route.endpoint)]] : []) as Array<[string, string]>),
1596
+ ["mode", modeLine("off")],
1597
+ ["config", inspection.path],
1598
+ ...((r.provider === JEV_CLOUD_PROVIDER ? [["key", cloudKeyRow()]] : []) as Array<[string, string]>),
1599
+ ],
1600
+ opts,
1601
+ ),
1602
+ nextStep("failproofai jev setup --mode shadow", "Switch it back on (shadow logs Jev and keeps enforcing regex; enforce lets it clear), here or from the dashboard:", opts),
1603
+ legacyNote,
1604
+ jevStatsLines(stats, opts),
1605
+ ),
1606
+ );
1607
+ }
1608
+
1609
+ if (inspection.status === "not-connected") {
1610
+ // The Cloud route's `key-missing`: nothing is wrong with the file, and the
1611
+ // remedy is the connection, not a rewrite.
1612
+ const r = inspection.routing;
1613
+ const route = routeForRouting(r);
1614
+ return ok(
1615
+ stack(
1616
+ title("failproofai jev status", "off — this machine is not connected to FailproofAI Cloud", opts),
1617
+ note(
1618
+ `Jev is off: ${inspection.path} sends Jev requests through FailproofAI Cloud, and this machine holds no FailproofAI Cloud key that carries jev:evaluate. ` +
1619
+ "Hooks run the regex policies exactly as before.",
1620
+ opts,
1621
+ ),
1622
+ rows(
1623
+ [
1624
+ ["provider", providerLabel(r.provider)],
1625
+ ...((route ? [["endpoint", shownEndpoint(r.provider, route.endpoint)]] : []) as Array<[string, string]>),
1626
+ ["mode", modeLine(r.mode ?? DEFAULT_JEV_MODE)],
1627
+ ["config", inspection.path],
1628
+ ["key", `${CLOUD_KEY_SOURCE} — not connected`],
1629
+ ],
1630
+ opts,
1631
+ ),
1632
+ nextStep(
1633
+ "failproofai config --token <key>",
1634
+ "Connect with a key that carries jev:evaluate (the \"machine\" preset on the dashboard's Keys page):",
1635
+ opts,
1636
+ ),
1637
+ nextStep("failproofai jev remove", "Or switch Jev off:", opts),
1638
+ legacyNote,
1639
+ jevStatsLines(stats, opts),
1640
+ ),
1641
+ );
1642
+ }
1643
+
1644
+ if (inspection.status === "key-lacks-jev") {
1645
+ // Connected, so "not connected" would be false and send its owner to
1646
+ // reconnect with the same key. The remedy is a key WITH Jev.
1647
+ const r = inspection.routing;
1648
+ const route = routeForRouting(r);
1649
+ return ok(
1650
+ stack(
1651
+ title("failproofai jev status", KEY_LACKS_JEV_TITLE, opts),
1652
+ note(
1653
+ `Jev is off: ${inspection.path} sends Jev requests through FailproofAI Cloud, and no Jev key is stored for this machine's connection: its key lacks jev:evaluate, or the last connect could not confirm that. ` +
1654
+ "Hooks run the regex policies exactly as before.",
1655
+ opts,
1656
+ ),
1657
+ rows(
1658
+ [
1659
+ ["provider", providerLabel(r.provider)],
1660
+ ...((route ? [["endpoint", shownEndpoint(r.provider, route.endpoint)]] : []) as Array<[string, string]>),
1661
+ ["mode", modeLine(r.mode ?? DEFAULT_JEV_MODE)],
1662
+ ["config", inspection.path],
1663
+ ["key", `${CLOUD_KEY_SOURCE} — connected, no Jev key stored for it`],
1664
+ ],
1665
+ opts,
1666
+ ),
1667
+ nextStep(
1668
+ "failproofai config --token <key>",
1669
+ "Reconnect with this machine's key to re-check what it carries (a key without jev:evaluate needs the \"machine\" preset on the dashboard's Keys page):",
1670
+ opts,
1671
+ ),
1672
+ nextStep("failproofai jev setup --mode off", "Or keep Jev off for good:", opts),
1673
+ legacyNote,
1674
+ jevStatsLines(stats, opts),
1675
+ ),
1676
+ );
1677
+ }
1678
+
1002
1679
  if (inspection.status === "refused") {
1003
1680
  // A file other users could change may name an endpoint its owner never
1004
1681
  // chose, and `chmod 600` would start trusting it with the key — so say
1005
1682
  // where it points before suggesting that.
1006
- const named = inspection.reason === "too-open" ? namedEndpoint(readJevConfigFileForUpdate()?.raw ?? null) : null;
1683
+ const raw = readJevConfigFileForUpdate()?.raw ?? null;
1684
+ const named = inspection.reason === "too-open" ? namedEndpoint(raw) : null;
1685
+ const next = refusedNextStep(inspection, cloudRoute(raw));
1007
1686
  return fail(
1008
1687
  stack(
1009
1688
  title("failproofai jev status", "off (config refused)", opts),
1010
1689
  warning([`Jev is off: ${inspection.path} was refused — ${inspection.problem}.`, "Hooks run the regex policies exactly as before."], opts),
1011
1690
  named ? rows([["endpoint it names", named]], opts) : null,
1012
- inspection.reason === "too-open"
1013
- ? nextStep(
1014
- // Either the file or the directory it sits in; `fix` says which.
1015
- inspection.fix ?? `chmod 600 ${inspection.path}`,
1016
- "Other users could change this file, so check that endpoint is one you chose. Then make it owner-only (or re-run `failproofai jev setup`, which asks for the key again unless the endpoint is the provider's own):",
1017
- opts,
1018
- )
1019
- : nextStep("failproofai jev setup --provider <kind> --key-stdin", "Write a valid one:", opts),
1691
+ nextStep(next.cmd, next.lead, opts),
1020
1692
  legacyNote,
1021
1693
  jevStatsLines(stats, opts),
1022
1694
  ),
@@ -1031,14 +1703,21 @@ async function status(argv: string[], opts: RenderOpts): Promise<JevCliResult> {
1031
1703
  title("failproofai jev status", legacy ? "on (legacy override in this shell)" : `on · ${mode}`, opts),
1032
1704
  rows(
1033
1705
  [
1034
- ["provider", cfg.provider],
1035
- ["endpoint", displayEndpoint(route.endpoint)],
1706
+ ["provider", providerLabel(cfg.provider)],
1707
+ ["endpoint", shownEndpoint(cfg.provider, route.endpoint)],
1036
1708
  ["model", route.modelIsDefault ? `${route.model} (provider default)` : route.model],
1037
1709
  ["mode", modeLine(mode)],
1038
1710
  ["timeout", `${cfg.timeoutMs} ms`],
1039
1711
  ["config", inspection.path],
1040
1712
  ["permissions", permissions(inspection.mode)],
1041
- ["key", inspection.keySource === "file" ? "set in the config file" : `from ${JEV_API_KEY_ENV} (this shell only; the daemon does not see it)`],
1713
+ [
1714
+ "key",
1715
+ inspection.keySource === "cloud"
1716
+ ? CLOUD_KEY_SOURCE
1717
+ : inspection.keySource === "file"
1718
+ ? "set in the config file"
1719
+ : `from ${JEV_API_KEY_ENV} (this shell only; the daemon does not see it)`,
1720
+ ],
1042
1721
  ],
1043
1722
  opts,
1044
1723
  ),
@@ -1079,19 +1758,51 @@ async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise
1079
1758
  ? `There is no ${inspection.path}; nothing to test.`
1080
1759
  : inspection.status === "key-missing"
1081
1760
  ? `${inspection.path} takes its key from ${JEV_API_KEY_ENV}, which is not set in this shell, so there is no key to test with.`
1082
- : `${inspection.path} was refused — ${inspection.problem}.`;
1083
- const code = inspection.status === "absent" ? "not-configured" : inspection.status === "key-missing" ? "no-env-key" : "config";
1084
- const fixCmd =
1085
- inspection.status === "key-missing" ? "failproofai jev setup --key-stdin < key-file" : "failproofai jev setup --provider <kind> --key-stdin";
1761
+ : inspection.status === "off"
1762
+ ? `Jev is switched off in ${inspection.path} (mode off), so nothing is sent.`
1763
+ : inspection.status === "not-connected"
1764
+ ? `${inspection.path} sends Jev requests through FailproofAI Cloud, and this machine is not connected to it with a key that carries jev:evaluate.`
1765
+ : inspection.status === "key-lacks-jev"
1766
+ ? `${inspection.path} sends Jev requests through FailproofAI Cloud, and no Jev key is stored for this machine's connection.`
1767
+ : `${inspection.path} was refused — ${inspection.problem}.`;
1768
+ const code =
1769
+ inspection.status === "absent"
1770
+ ? "not-configured"
1771
+ : inspection.status === "key-missing"
1772
+ ? "no-env-key"
1773
+ : inspection.status === "off"
1774
+ ? "switched-off"
1775
+ : inspection.status === "not-connected" || inspection.status === "key-lacks-jev"
1776
+ ? inspection.status
1777
+ : "config";
1778
+ const refusedNext =
1779
+ inspection.status === "refused"
1780
+ ? refusedNextStep(inspection, cloudRoute(readJevConfigFileForUpdate()?.raw))
1781
+ : inspection.status === "absent" && cloudCredentialPresent()
1782
+ ? { cmd: "failproofai jev setup --provider failproofai", lead: "This machine's FailproofAI Cloud key already carries jev:evaluate. Turn it on:" }
1783
+ : null;
1784
+ const fixCmd = refusedNext
1785
+ ? refusedNext.cmd
1786
+ : inspection.status === "key-missing"
1787
+ ? "failproofai jev setup --key-stdin < key-file"
1788
+ : inspection.status === "off"
1789
+ ? "failproofai jev setup --mode shadow"
1790
+ : inspection.status === "not-connected" || inspection.status === "key-lacks-jev"
1791
+ ? "failproofai config --token <key>"
1792
+ : "failproofai jev setup --provider <kind> --key-stdin";
1793
+ const fixLead = refusedNext
1794
+ ? refusedNext.lead
1795
+ : inspection.status === "key-missing"
1796
+ ? `Set ${JEV_API_KEY_ENV} for this shell, or store the key in the file:`
1797
+ : inspection.status === "off"
1798
+ ? "Switch it back on:"
1799
+ : inspection.status === "not-connected"
1800
+ ? "Connect with a key that carries jev:evaluate:"
1801
+ : inspection.status === "key-lacks-jev"
1802
+ ? "Reconnect with this machine's key to re-check what it carries (a key without jev:evaluate needs the \"machine\" preset on the dashboard's Keys page):"
1803
+ : undefined;
1086
1804
  const json = asJson ? JSON.stringify({ ok: false, error: { code, message: why } }, null, 2) : undefined;
1087
- return fail(
1088
- stack(
1089
- title("failproofai jev test", "not run", opts),
1090
- note(why, opts),
1091
- nextStep(fixCmd, inspection.status === "key-missing" ? `Set ${JEV_API_KEY_ENV} for this shell, or store the key in the file:` : undefined, opts),
1092
- ),
1093
- json,
1094
- );
1805
+ return fail(stack(title("failproofai jev test", "not run", opts), note(why, opts), nextStep(fixCmd, fixLead, opts)), json);
1095
1806
  }
1096
1807
 
1097
1808
  const cfg = inspection.config;
@@ -1119,12 +1830,16 @@ async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise
1119
1830
  const within = latencyMs <= budget;
1120
1831
  const p = answers[JEV_TEST_QUESTION_ID];
1121
1832
  const inputTokens = typeof response.usage?.input_tokens === "number" ? response.usage.input_tokens : null;
1833
+ // "ok" means a hook would use this answer: in time, and right about "blue".
1834
+ const problem = !within ? "over-timeout" : p < 0.5 ? "unexpected-answer" : null;
1835
+ const done = problem ? fail : ok;
1122
1836
  if (asJson) {
1123
- return ok(
1837
+ return done(
1124
1838
  [],
1125
1839
  JSON.stringify(
1126
1840
  {
1127
- ok: true,
1841
+ ok: problem === null,
1842
+ ...(problem ? { problem } : {}),
1128
1843
  provider: cfg.provider,
1129
1844
  endpoint: displayEndpoint(route.endpoint),
1130
1845
  model: built.model,
@@ -1141,9 +1856,11 @@ async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise
1141
1856
  ),
1142
1857
  );
1143
1858
  }
1144
- return ok(
1859
+ const status =
1860
+ problem === "over-timeout" ? `over timeout · ${latencyMs} ms` : problem ? `wrong answer · p = ${p.toFixed(3)}` : `ok · ${latencyMs} ms`;
1861
+ return done(
1145
1862
  stack(
1146
- title("failproofai jev test", `ok · ${latencyMs} ms`, opts),
1863
+ title("failproofai jev test", status, opts),
1147
1864
  rows(
1148
1865
  [
1149
1866
  ["provider", cfg.provider],
@@ -1163,6 +1880,9 @@ async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise
1163
1880
  "One request, sent directly: the hook path's cache and rate limit were not involved, and a fresh process pays DNS and TLS setup that the daemon's warm worker does not.",
1164
1881
  opts,
1165
1882
  ),
1883
+ problem === "over-timeout"
1884
+ ? nextStep("failproofai jev setup --timeout-ms <n>", `Give hooks longer (up to ${MAX_JEV_TIMEOUT_MS} ms), or use a faster route:`, opts)
1885
+ : null,
1166
1886
  ),
1167
1887
  );
1168
1888
  } catch (err) {
@@ -1170,7 +1890,10 @@ async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise
1170
1890
  // jev-client scrubs provider text already; this is the last line of that
1171
1891
  // defence, for any error text that reached here some other way.
1172
1892
  const e = scrubbed(describeError(err), cfg.apiKey);
1173
- if (asJson) return fail([], JSON.stringify({ ok: false, provider: cfg.provider, latencyMs, error: e }, null, 2));
1893
+ // An answer after the hook budget is a `timeout` to every hook, whatever it
1894
+ // finally said (a Cloud route maps its own upstream timeout to 502).
1895
+ const late = latencyMs > budget;
1896
+ if (asJson) return fail([], JSON.stringify({ ok: false, provider: cfg.provider, latencyMs, timeoutMs: budget, withinTimeout: !late, error: e }, null, 2));
1174
1897
  return fail(
1175
1898
  stack(
1176
1899
  title("failproofai jev test", `failed · ${e.code}`, opts),
@@ -1180,14 +1903,212 @@ async function test(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise
1180
1903
  ["endpoint", displayEndpoint(route.endpoint)],
1181
1904
  ["model asked", built.model],
1182
1905
  ["error", `${e.code}: ${e.message}`],
1183
- ["after", `${latencyMs} ms`],
1906
+ ["after", late ? `${latencyMs} ms — OVER the ${budget} ms timeout: hooks would fall back to regex (timeout)` : `${latencyMs} ms`],
1907
+ ],
1908
+ opts,
1909
+ ),
1910
+ note(
1911
+ late
1912
+ ? `Hooks stop waiting after ${budget} ms, so they record this as \`timeout\`, not ${e.code}: the provider or FailproofAI Cloud is slow or failing upstream.`
1913
+ : remedy(e.code, cfg.provider, e.message),
1914
+ opts,
1915
+ ),
1916
+ ),
1917
+ );
1918
+ }
1919
+ }
1920
+
1921
+ // ── models ───────────────────────────────────────────────────────────────────
1922
+
1923
+ /** What a shape means, which decides what the names in it can be used for. */
1924
+ function shapeMeans(shape: JevModelListRead["shape"]): string {
1925
+ return shape === "typesafe"
1926
+ ? "TypeSafe's own inventory — the aliases <base>/systemone answers to; versioned ids such as jev-1.13.0 are accepted without being listed"
1927
+ : "an OpenAI-shaped gateway catalog — it lists what the gateway serves, not what <base>/systemone answers to, so it is shown and never used to refuse a model";
1928
+ }
1929
+
1930
+ /**
1931
+ * `failproofai jev models` — what `GET <base>/models` says an endpoint serves.
1932
+ *
1933
+ * The question a person asks after `jev test` fails, and the one the LiteLLM-proxy
1934
+ * customer answered by curling the endpoint by hand. With no flags it asks the
1935
+ * configured route; `--provider` asks a provider's own API and `--url` any base.
1936
+ *
1937
+ * The stored key is sent only to the origin it was stored for. `--url` takes a URL
1938
+ * off the command line, and a key issued for one gateway must not go to another
1939
+ * because a diagnostic named it — the same rule `setup` applies when a new
1940
+ * `--base-url` moves the host. Vercel's and OpenRouter's lists are public, so an
1941
+ * unkeyed read is often still an answer, and the output says which it was.
1942
+ */
1943
+ async function models(argv: string[], deps: JevCliDeps, opts: RenderOpts): Promise<JevCliResult> {
1944
+ // The same test the launcher uses to print `json` instead of the lines, so a
1945
+ // refusal made before the flags parse is JSON as well: every `--json` outcome
1946
+ // is an object, as `jev test --json`'s are.
1947
+ const asJson = argv.includes("--json");
1948
+ const refuse = (code: string, lines: string[]) =>
1949
+ fail(lines, asJson ? JSON.stringify({ ok: false, error: { code, message: lines.join("\n") } }, null, 2) : undefined);
1950
+ const parsed = parseFlags(argv, new Set(["--json", "--provider", "--url"]));
1951
+ if (typeof parsed === "string") return refuse("usage", [parsed, "", ...JEV_USAGE]);
1952
+ if (parsed.positionals.length > 0) return refuse("usage", [STRAY_ARGUMENT, "", ...JEV_USAGE]);
1953
+ const { values } = parsed;
1954
+
1955
+ // Checked before anything else uses it, and never echoed — the same rule
1956
+ // `setup` follows, for the same reason: the likeliest wrong value is a key.
1957
+ const named = values.get("--provider");
1958
+ if (named !== undefined && !(JEV_PROVIDER_KINDS as readonly string[]).includes(named)) {
1959
+ return refuse("unknown-provider", [
1960
+ "Unknown provider (not repeated here, in case it is a key).",
1961
+ `Providers: ${JEV_PROVIDER_KINDS.join(", ")} — exactly as spelled here, lower-case.`,
1962
+ ]);
1963
+ }
1964
+
1965
+ // The configured route, for the default base, for the key, and for the model to
1966
+ // mark. `key-missing` counts: the file's routing is sound, it just has no key
1967
+ // here, and a public list answers anyway.
1968
+ const inspection = inspectJevConfig();
1969
+ const routing: Omit<JevConfig, "apiKey"> | null =
1970
+ inspection.status === "ok"
1971
+ ? inspection.config
1972
+ : inspection.status === "key-missing" ||
1973
+ inspection.status === "off" ||
1974
+ inspection.status === "not-connected" ||
1975
+ inspection.status === "key-lacks-jev"
1976
+ ? inspection.routing
1977
+ : null;
1978
+ const storedKey = inspection.status === "ok" ? inspection.config.apiKey : null;
1979
+
1980
+ let provider: JevProviderKind;
1981
+ let base: string | null;
1982
+ const urlArg = values.get("--url");
1983
+ if (urlArg !== undefined) {
1984
+ const checked = validateBaseUrl(urlArg);
1985
+ if (!checked.ok) return refuse("bad-url", [`Cannot read a model list: ${checked.problem}.`]);
1986
+ const asEndpoint = endpointGivenAsBase(checked.value);
1987
+ if (asEndpoint) return refuse("endpoint-as-base", endpointAsBaseRefusal("--url", checked.value, asEndpoint));
1988
+ base = checked.value;
1989
+ provider = (named as JevProviderKind | undefined) ?? providerForUrl(base);
1990
+ } else if (named !== undefined) {
1991
+ provider = named as JevProviderKind;
1992
+ base = JEV_PROVIDER_DEFAULTS[provider].baseUrl;
1993
+ if (base === null && provider === JEV_CLOUD_PROVIDER) {
1994
+ return refuse("no-model-list", [
1995
+ `FailproofAI Cloud serves no model list: it runs ${JEV_PROVIDER_DEFAULTS.failproofai.model}, pinned on the server, and every answer is checked against the Jev 1.13 family.`,
1996
+ ]);
1997
+ }
1998
+ if (base === null) {
1999
+ return refuse("no-api", ["Provider custom has no API of its own — its URL is the whole address.", " failproofai jev models --url <base>"]);
2000
+ }
2001
+ } else if (routing !== null) {
2002
+ provider = routing.provider;
2003
+ base = routing.baseUrl ?? JEV_PROVIDER_DEFAULTS[routing.provider].baseUrl;
2004
+ } else {
2005
+ return refuse("not-configured", [
2006
+ `Jev is not configured here (no ${inspection.path}), so there is no endpoint to ask.`,
2007
+ "Name one:",
2008
+ " failproofai jev models --provider vercel",
2009
+ " failproofai jev models --url https://your-proxy.example.com/typesafe/v1",
2010
+ ]);
2011
+ }
2012
+
2013
+ // Cloudflare is the one route with no `<base>/models`: Workers AI runs models at
2014
+ // `/accounts/<id>/ai/run` and keeps its inventory behind a different API, so
2015
+ // there is nothing here to read rather than something that failed to read.
2016
+ // FailproofAI Cloud serves no list either: it pins the model server-side
2017
+ // (`jev-1.13.0`), so there is nothing to choose and nothing to read.
2018
+ const modelsUrl = provider === "cloudflare" || provider === JEV_CLOUD_PROVIDER || base === null ? null : modelsUrlForBase(base);
2019
+ if (modelsUrl === null) {
2020
+ return refuse("no-model-list", [
2021
+ provider === "cloudflare"
2022
+ ? "Cloudflare Workers AI serves no <base>/models: its models are listed through the Cloudflare API, not through the Jev base URL."
2023
+ : provider === JEV_CLOUD_PROVIDER
2024
+ ? `FailproofAI Cloud serves no model list: it runs ${JEV_PROVIDER_DEFAULTS.failproofai.model}, pinned on the server, and every answer is checked against the Jev 1.13 family.`
2025
+ : "That base URL names no endpoint a model list could be read from.",
2026
+ ]);
2027
+ }
2028
+
2029
+ const askOrigin = originOf(provider, base);
2030
+ const configuredOrigin = routing !== null ? originOf(routing.provider, routing.baseUrl) : null;
2031
+ const sameOrigin = askOrigin !== null && askOrigin === configuredOrigin;
2032
+ const apiKey = sameOrigin ? storedKey : null;
2033
+ // Only meaningful for the route that is actually configured: marking a name on
2034
+ // some other host as "configured" would be saying something untrue.
2035
+ const configuredModel = sameOrigin && routing !== null ? (routing.model ?? JEV_PROVIDER_DEFAULTS[routing.provider].model) : null;
2036
+
2037
+ let list: JevModelListResult;
2038
+ try {
2039
+ list = await (deps.readModelList ?? liveModelListReader)(modelsUrl, apiKey);
2040
+ } catch (err) {
2041
+ list = { ok: false, reason: scrubSecret(err instanceof Error ? err.message : String(err), apiKey ?? "") };
2042
+ }
2043
+
2044
+ const shown = displayEndpoint(modelsUrl);
2045
+ const keyNote = apiKey
2046
+ ? "the configured key for this origin"
2047
+ : sameOrigin
2048
+ ? `none stored here — set ${JEV_API_KEY_ENV}, or store one with \`failproofai jev setup --key-stdin\``
2049
+ : storedKey !== null
2050
+ ? "none sent: a stored key belongs to the endpoint it was stored for"
2051
+ : "none: nothing is configured for this endpoint, and some lists are public";
2052
+
2053
+ if (!list.ok) {
2054
+ if (asJson) {
2055
+ return fail([], JSON.stringify({ ok: false, provider, endpoint: shown, error: { code: "model-list-unread", message: list.reason } }, null, 2));
2056
+ }
2057
+ return fail(
2058
+ stack(
2059
+ title("failproofai jev models", "not read", opts),
2060
+ rows(
2061
+ [
2062
+ ["provider", provider],
2063
+ ["endpoint", shown],
2064
+ ["key", keyNote],
2065
+ ["reason", list.reason],
1184
2066
  ],
1185
2067
  opts,
1186
2068
  ),
1187
- note(remedy(e.code), opts),
2069
+ note(
2070
+ "Not every endpoint serves a model list — a proxy may expose only /systemone — and nothing depends on one: setup and hooks work without it.",
2071
+ opts,
2072
+ ),
1188
2073
  ),
1189
2074
  );
1190
2075
  }
2076
+
2077
+ if (asJson) {
2078
+ return ok(
2079
+ [],
2080
+ JSON.stringify({ ok: true, provider, endpoint: shown, shape: list.shape, models: list.models, configuredModel, keySent: apiKey !== null }, null, 2),
2081
+ );
2082
+ }
2083
+ const listed = list.models.slice(0, MAX_SHOWN_MODELS);
2084
+ return ok(
2085
+ stack(
2086
+ title("failproofai jev models", `${list.models.length} · ${provider}`, opts),
2087
+ rows(
2088
+ [
2089
+ ["provider", provider],
2090
+ ["endpoint", shown],
2091
+ ["key", keyNote],
2092
+ ["list", shapeMeans(list.shape)],
2093
+ ],
2094
+ opts,
2095
+ ),
2096
+ rows(
2097
+ listed.map((name) => [name, name === configuredModel ? "configured" : ""] as [string, string]),
2098
+ opts,
2099
+ ),
2100
+ list.models.length > listed.length ? note(`… and ${list.models.length - listed.length} more.`, opts) : null,
2101
+ configuredModel !== null && !modelListHasModel(list, configuredModel) && listDescribesSystemOne(list)
2102
+ ? warning(
2103
+ [
2104
+ `The configured model ${configuredModel} is not one of these, so every evaluation would fail at this endpoint and hooks would fall back to regex.`,
2105
+ `Point it at one of them: failproofai jev setup --model ${list.models[0]}`,
2106
+ ],
2107
+ opts,
2108
+ )
2109
+ : null,
2110
+ ),
2111
+ );
1191
2112
  }
1192
2113
 
1193
2114
  // ── remove ───────────────────────────────────────────────────────────────────
@@ -1232,6 +2153,8 @@ export async function runJevCommand(argv: string[], deps: JevCliDeps = {}): Prom
1232
2153
  return status(rest, opts);
1233
2154
  case "test":
1234
2155
  return test(rest, deps, opts);
2156
+ case "models":
2157
+ return models(rest, deps, opts);
1235
2158
  case "remove":
1236
2159
  return remove(rest, opts);
1237
2160
  default: {