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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -5
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +4 -4
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +6 -7
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +18 -7
- package/.next/standalone/.next/server/app/settings/page.js +9 -7
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1j-zsg5._.js → [externals]__20kzpkf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l3yhx4._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +9 -9
- package/.next/standalone/.next/server/chunks/_1q5i8mb._.js +1 -1
- package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
- package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0qxnccm._.js → [root-of-the-server]__06pflha._.js} +2 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0soxz2z._.js → [root-of-the-server]__12e7nhs._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0l44ual._.js → [root-of-the-server]__14o3ek1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1v-jvrv._.js → _0o4xkpl._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1mel6y1._.js → _1-7sqrb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +13 -13
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +12 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +33 -22
- package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +1 -0
- package/.next/standalone/.next/static/chunks/{3brze37td_wnc.js → 0ahfwmbkpgfw4.js} +1 -1
- package/.next/standalone/.next/static/chunks/{043j99m8ykg__.css → 0bhidk90e-07f.css} +1 -1
- package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +1 -0
- package/.next/standalone/.next/static/chunks/{3ugmd_7dyn0id.js → 1v_tp3hm8wyhe.js} +1 -1
- package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +1 -0
- package/.next/standalone/.next/static/chunks/{3yxro_r2_o9ad.js → 2vo7qbdbvnc0o.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fqd7m_u81mi5.js → 2z7i-yg59w4pn.js} +1 -1
- package/.next/standalone/.next/static/chunks/36uhh9el_oz8e.js +1 -0
- package/.next/standalone/.next/static/chunks/{129ag2bw93bdh.js → 3bgot5v6f6s3c.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3otmypm6j_xfo.js → 3o3f1ibfci0p7.js} +2 -2
- package/.next/standalone/SECURITY.md +53 -0
- package/.next/standalone/app/actions/get-jev-config.ts +194 -14
- package/.next/standalone/app/actions/update-jev-config.ts +160 -11
- package/.next/standalone/app/policies/hooks-client.tsx +5 -3
- package/.next/standalone/app/settings/jev-panel.tsx +169 -12
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
- package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
- package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
- package/.next/standalone/sdk/typescript/CHANGELOG.md +15 -1
- package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package-lock.json +10 -30
- package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package.json +3 -0
- package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package-lock.json +4 -16
- package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package.json +3 -0
- package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package-lock.json +13 -132
- package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package.json +4 -0
- package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package-lock.json +4 -142
- package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package.json +4 -0
- package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package-lock.json +1396 -1016
- package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package.json +9 -0
- package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +1 -0
- package/bin/failproofai.mjs +37 -6
- package/dist/cli.mjs +2861 -1145
- package/dist/worker.mjs +815 -197
- package/package.json +9 -9
- package/src/hooks/builtin-policies.ts +24 -6
- package/src/hooks/cloud-connection.ts +190 -1
- package/src/hooks/cloud-enrollment-cli.ts +56 -7
- package/src/hooks/cloud-introspect.ts +6 -0
- package/src/hooks/configure-wizard.ts +26 -6
- package/src/hooks/custom-hooks-loader.ts +58 -8
- package/src/hooks/effective-reviewers.ts +172 -8
- package/src/hooks/flush-cli.ts +35 -8
- package/src/hooks/fp-config.ts +266 -1
- package/src/hooks/fp-reset.ts +22 -4
- package/src/hooks/handler.ts +44 -7
- package/src/hooks/hook-activity-store.ts +13 -5
- package/src/hooks/jev-cli.ts +1009 -86
- package/src/hooks/jev-cloud-connection.ts +392 -0
- package/src/hooks/manager.ts +44 -6
- package/src/hooks/pack-cli.ts +175 -37
- package/src/hooks/pack-failclosed.ts +3 -0
- package/src/hooks/pack-manifest.ts +11 -1
- package/src/hooks/pack-store.ts +47 -10
- package/src/hooks/policy-authority.ts +73 -20
- package/src/hooks/policy-catalog.ts +10 -8
- package/src/hooks/policy-evaluator.ts +31 -8
- package/src/hooks/policy-registry.ts +3 -2
- package/src/hooks/policy-reviewability.ts +41 -12
- package/src/hooks/policy-types.ts +5 -3
- package/src/hooks/semantic/combine.ts +124 -84
- package/src/hooks/semantic/decide.ts +16 -0
- package/src/hooks/semantic/envelope.ts +18 -59
- package/src/hooks/semantic/evaluator.ts +11 -2
- package/src/hooks/semantic/facts.ts +7 -1
- package/src/hooks/semantic/intent.ts +8 -8
- package/src/hooks/semantic/jev-client.ts +540 -28
- package/src/hooks/semantic/jev-config.ts +586 -38
- package/src/hooks/semantic/jev-review.ts +26 -11
- package/src/hooks/semantic/jev-throttle.ts +8 -2
- package/src/hooks/semantic/pack-policies.ts +58 -15
- package/src/hooks/semantic/policies.ts +4 -3
- package/src/hooks/semantic/redact.ts +28 -1
- package/src/hooks/semantic/session-root.ts +116 -0
- package/src/hooks/semantic/types.ts +11 -0
- package/src/hooks/tui.ts +4 -0
- package/src/hooks/worker-server.ts +2 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1bf34x4._.js +0 -22
- package/.next/standalone/.next/server/chunks/src_hooks_01frwmb._.js +0 -5
- package/.next/standalone/.next/server/chunks/src_hooks_18qtd42._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s_yomn._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_1feuvhb._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_1w_5l7t._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_095a_79._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_15t8kqj._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_18k8rl0._.js +0 -12
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1fm2w5z._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1j0zy3v._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
- package/.next/standalone/.next/static/chunks/1qd741hzlmjbo.js +0 -1
- package/.next/standalone/.next/static/chunks/2_pltstd8-xgs.js +0 -1
- package/.next/standalone/.next/static/chunks/2c8j9l6j_b1ci.js +0 -1
- package/.next/standalone/.next/static/chunks/3-k569wzcli8q.js +0 -1
- /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_ssgManifest.js +0 -0
|
@@ -2,15 +2,33 @@
|
|
|
2
2
|
* The client for Jev, and the provider layer that turns a customer's own
|
|
3
3
|
* config (BYOK, `jev-config.ts`) into a transport.
|
|
4
4
|
*
|
|
5
|
-
* #
|
|
5
|
+
* # Six routes, two wire shapes
|
|
6
6
|
*
|
|
7
|
-
* | Provider
|
|
8
|
-
*
|
|
9
|
-
* | typesafe
|
|
10
|
-
* | openrouter
|
|
11
|
-
* | vercel
|
|
12
|
-
* | cloudflare
|
|
13
|
-
* | custom
|
|
7
|
+
* | Provider | Endpoint | Default model |
|
|
8
|
+
* |-------------|-------------------------------------------------------|----------------------|
|
|
9
|
+
* | typesafe | `https://api.typesafe.ai/v1/systemone` | `jev-1.13.0` |
|
|
10
|
+
* | openrouter | `https://openrouter.ai/api/v1/systemone` | `typesafe/jev-1.13` |
|
|
11
|
+
* | vercel | `https://ai-gateway.vercel.sh/typesafe/v1/systemone` | `typesafe-ai/jev` |
|
|
12
|
+
* | cloudflare | `https://api.cloudflare.com/client/v4/accounts/<id>/ai/run` | `typesafe/jev` |
|
|
13
|
+
* | custom | `<baseUrl>/systemone` | `jev-1.13.0` |
|
|
14
|
+
* | failproofai | `<Cloud origin>/enforcement/v1/jev/systemone` | `jev-1.13.0` |
|
|
15
|
+
*
|
|
16
|
+
* `failproofai` is FailproofAI Cloud: the machine's own Cloud key (the `jev`
|
|
17
|
+
* slot of `credentials.json`, see `jev-config.ts`) as the bearer, the org's
|
|
18
|
+
* plan allowance as the budget. The server forces `jev-1.13.0` and passes
|
|
19
|
+
* TypeSafe's answer through with its `model`, so this route accepts only a
|
|
20
|
+
* reported 1.13 model, never silence. Its documented statuses map like every
|
|
21
|
+
* other route's: 402 `{"error":"out_of_credits"}` → `out-of-credits` (the body
|
|
22
|
+
* never says "model execution failed"), 400/401/403/413/422/429/502/503 →
|
|
23
|
+
* `http-<status>`, and a redirect is refused. After a 429 the route sends
|
|
24
|
+
* nothing for as long as its `Retry-After` asks (capped; `cloudRetryAfter`).
|
|
25
|
+
*
|
|
26
|
+
* `baseUrl` is a BASE — the provider's version root — and `/systemone` is this
|
|
27
|
+
* file's own suffix (`nativeEndpoint`). Measured 2026-09-25, unauthenticated:
|
|
28
|
+
* `POST <base>/systemone` answers 403 / 401 / 400 / 401 on the four bases above
|
|
29
|
+
* and NEVER 404, so a 404 from it is evidence that the base is wrong rather
|
|
30
|
+
* than that the service is down — which is what `postJson` says when it sees
|
|
31
|
+
* one.
|
|
14
32
|
*
|
|
15
33
|
* TypeSafe, OpenRouter, Vercel and a custom URL all take TypeSafe's native body
|
|
16
34
|
* `{model, state, questions}` and answer `{model, answers, usage}`, so one
|
|
@@ -25,6 +43,62 @@
|
|
|
25
43
|
* Auth everywhere is `Authorization: Bearer <key>`. The key never appears in an
|
|
26
44
|
* error message: provider error text is passed through with the key scrubbed.
|
|
27
45
|
*
|
|
46
|
+
* # `GET <base>/models`: two shapes, and only one of them answers the question
|
|
47
|
+
*
|
|
48
|
+
* Measured 2026-09-25, unauthenticated except where noted:
|
|
49
|
+
*
|
|
50
|
+
* | base | status | body |
|
|
51
|
+
* |------------------------------------------|--------|--------------------------------------------------------|
|
|
52
|
+
* | `https://api.typesafe.ai/v1` | 403 | `{detail:{error_type,message}}` |
|
|
53
|
+
* | `https://openrouter.ai/api/v1` | 200 | `{data:[{id,canonical_slug,name,…}]}` — OpenAI shape |
|
|
54
|
+
* | `https://ai-gateway.vercel.sh/typesafe/v1` | 200 | `{models:[{name,description,release_date}]}` — TypeSafe shape |
|
|
55
|
+
* | a LiteLLM proxy (`…/typesafe/v1`) | 401 | `{error:{message,type,param,code}}` |
|
|
56
|
+
* | the same proxy, with a key | 200 | TypeSafe shape: `jev-latest`, `jev-preview` |
|
|
57
|
+
*
|
|
58
|
+
* `readJevModelList` parses both shapes, and `parseJevModelList` reports "could
|
|
59
|
+
* not read the list" rather than throwing, because a proxy may serve neither.
|
|
60
|
+
*
|
|
61
|
+
* The two shapes do not mean the same thing, and only one of them may be used to
|
|
62
|
+
* REFUSE a model (`jev setup`, see `listDescribesSystemOne`):
|
|
63
|
+
*
|
|
64
|
+
* - The TypeSafe shape is the System One inventory of the base it was read from,
|
|
65
|
+
* as ALIASES. Vercel's typesafe-scoped passthrough and a LiteLLM passthrough
|
|
66
|
+
* both return it. It is not exhaustive: the models.aikin.club upstream lists
|
|
67
|
+
* only `jev-latest` and `jev-preview`, yet its `/systemone` answers
|
|
68
|
+
* `jev-1.13.0` (and "Unknown model" for `jev-1.13` or `jev-1.13.5`; measured
|
|
69
|
+
* 2026-09-27). So it may refuse an unlisted alias, never a versioned id.
|
|
70
|
+
* - The OpenAI shape is a gateway's chat-completions catalog, and demonstrably
|
|
71
|
+
* does not enumerate `/systemone`: OpenRouter's 458-model catalog contains no
|
|
72
|
+
* Jev entry of any spelling, while `POST https://openrouter.ai/api/v1/systemone`
|
|
73
|
+
* exists (401, not 404) and this table addresses it as `typesafe/jev-1.13`. So
|
|
74
|
+
* an OpenAI-shaped list is shown to a person and never used to refuse one.
|
|
75
|
+
*
|
|
76
|
+
* # Vercel names the same model twice, once per base
|
|
77
|
+
*
|
|
78
|
+
* `JEV_PROVIDER_DEFAULTS.vercel.model` is `typesafe-ai/jev` while Vercel's own
|
|
79
|
+
* `/typesafe/v1/models` calls it `jev`. Both are real, at two different bases
|
|
80
|
+
* (measured 2026-09-25):
|
|
81
|
+
*
|
|
82
|
+
* - `GET https://ai-gateway.vercel.sh/v1/models` — the gateway's own
|
|
83
|
+
* OpenAI-shaped catalog, 390 models — carries exactly one match:
|
|
84
|
+
* `{id: "typesafe-ai/jev", owned_by: "typesafe-ai", name: "Jev", type: "evaluation"}`.
|
|
85
|
+
* `<owner>/<model>` is how that catalog addresses every model in it
|
|
86
|
+
* (`alibaba/qwen-3-14b`, …), so the longer form is the GATEWAY-level id.
|
|
87
|
+
* - `GET https://ai-gateway.vercel.sh/typesafe/v1/models` — the typesafe-scoped
|
|
88
|
+
* passthrough, which is the base we POST to — reports the provider-native
|
|
89
|
+
* `jev`.
|
|
90
|
+
*
|
|
91
|
+
* Which one `POST /typesafe/v1/systemone` wants cannot be settled from outside:
|
|
92
|
+
* that route validates the BODY before the key (400 `model: Invalid input:
|
|
93
|
+
* expected string, received undefined` with no model, 401 `Authentication
|
|
94
|
+
* failed` with any model, valid or not), so an unauthenticated probe cannot
|
|
95
|
+
* distinguish a model it knows from one it does not. The default is therefore
|
|
96
|
+
* left as it is — it is what the route was configured and verified with — and
|
|
97
|
+
* `modelListHasModel` treats a listed `jev` as covering a configured
|
|
98
|
+
* `typesafe-ai/jev`, since Vercel's two catalogs differ by exactly that
|
|
99
|
+
* `<owner>/` prefix. Settling it needs one authenticated request with each
|
|
100
|
+
* spelling.
|
|
101
|
+
*
|
|
28
102
|
* # Which Jev answered
|
|
29
103
|
*
|
|
30
104
|
* The decision thresholds were calibrated against Jev 1.13, so `readAnswers`
|
|
@@ -50,6 +124,14 @@
|
|
|
50
124
|
* `cloudflare-error`, `cloudflare-incomplete`, `malformed`, `model-mismatch`,
|
|
51
125
|
* `config`.
|
|
52
126
|
*
|
|
127
|
+
* The CODE is the stable part — the activity store keeps a closed list of them
|
|
128
|
+
* (`JEV_REASON_CODE_LIST`, and its twin in `fpai-collect`) — so the provider's
|
|
129
|
+
* own sentence goes in the MESSAGE, which nothing parses.
|
|
130
|
+
* `providerErrorDetail` reads it out of whichever envelope arrived; all three
|
|
131
|
+
* observed shapes are in that comment, and one of them (TypeSafe's
|
|
132
|
+
* `{detail:{message}}`) used to be dropped on the floor, which is how "Must
|
|
133
|
+
* supply an API key!" reached a person as the bare words `HTTP 403`.
|
|
134
|
+
*
|
|
53
135
|
* # When the provider refuses the call
|
|
54
136
|
*
|
|
55
137
|
* A refusal is not spread evenly over the traffic: the provider is likeliest to
|
|
@@ -91,8 +173,9 @@ import { JEV_REASON_PROVIDER_REFUSED } from "../jev-activity";
|
|
|
91
173
|
import {
|
|
92
174
|
CLOUDFLARE_ACCOUNT_ID_RE,
|
|
93
175
|
isCalibratedJevModel,
|
|
176
|
+
isModelIdShaped,
|
|
94
177
|
jevModelVersion,
|
|
95
|
-
|
|
178
|
+
validateLoadedJevConfig,
|
|
96
179
|
type JevConfig,
|
|
97
180
|
type JevProviderKind,
|
|
98
181
|
} from "./jev-config";
|
|
@@ -120,6 +203,10 @@ export const JEV_PROVIDER_DEFAULTS = {
|
|
|
120
203
|
vercel: { baseUrl: "https://ai-gateway.vercel.sh/typesafe/v1", model: VERCEL_JEV_MODEL },
|
|
121
204
|
cloudflare: { baseUrl: "https://api.cloudflare.com/client/v4", model: CLOUDFLARE_JEV_MODEL },
|
|
122
205
|
custom: { baseUrl: null, model: "jev-1.13.0" },
|
|
206
|
+
// No default base: it is the Cloud origin this machine connected to, written
|
|
207
|
+
// into jev.json by `config --token` and checked against the credential's own
|
|
208
|
+
// origin by the loader. The server forces `jev-1.13.0` whatever is sent.
|
|
209
|
+
failproofai: { baseUrl: null, model: "jev-1.13.0" },
|
|
123
210
|
} as const satisfies Record<JevProviderKind, { baseUrl: string | null; model: string }>;
|
|
124
211
|
|
|
125
212
|
/**
|
|
@@ -139,10 +226,17 @@ export class JevError extends Error {
|
|
|
139
226
|
* cloudflare-error, cloudflare-incomplete, malformed, model-mismatch, config.
|
|
140
227
|
*/
|
|
141
228
|
readonly code: string;
|
|
142
|
-
|
|
143
|
-
|
|
229
|
+
/**
|
|
230
|
+
* An HTTP 429's `Retry-After` header, verbatim (null when it sent none).
|
|
231
|
+
* Recorded for every route and read by the FailproofAI Cloud one only (see
|
|
232
|
+
* `cloudRetryAfter`); a BYOK route behaves exactly as it did without it.
|
|
233
|
+
*/
|
|
234
|
+
readonly retryAfter: string | null;
|
|
235
|
+
constructor(code: string, message: string, opts: { retryAfter?: string | null } = {}) {
|
|
236
|
+
super(printable(message));
|
|
144
237
|
this.name = "JevError";
|
|
145
238
|
this.code = code;
|
|
239
|
+
this.retryAfter = opts.retryAfter ?? null;
|
|
146
240
|
}
|
|
147
241
|
}
|
|
148
242
|
|
|
@@ -200,8 +294,42 @@ export function transportFor(provider: JevProvider): JevTransport {
|
|
|
200
294
|
|
|
201
295
|
const MAX_ERROR_DETAIL = 300;
|
|
202
296
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
297
|
+
/**
|
|
298
|
+
* The provider's own words about a failure, from whichever envelope it uses,
|
|
299
|
+
* with the key scrubbed. Every shape below was observed live; none is guessed at,
|
|
300
|
+
* because a shape nobody has seen adds a branch that can only ever misread a
|
|
301
|
+
* body some future provider sends.
|
|
302
|
+
*
|
|
303
|
+
* | shape | seen on |
|
|
304
|
+
* |-----------------------------|------------------------------------------------------|
|
|
305
|
+
* | `{errors:[{message}]}` | Cloudflare Workers AI |
|
|
306
|
+
* | `{error:{message}}` | OpenRouter, and LiteLLM-style proxies |
|
|
307
|
+
* | `{error:"…"}` | gateways that report an upstream failure as a string |
|
|
308
|
+
* | `{message}` | Vercel AI Gateway (`400 model: Invalid input: …`) |
|
|
309
|
+
* | `{detail:{message}}` | TypeSafe direct (`403 Must supply an API key!`) |
|
|
310
|
+
* | `{detail:"…"}` | a FastAPI 404 behind a proxy (`Not Found`) |
|
|
311
|
+
*
|
|
312
|
+
* `{detail:{message}}` is the one that was missing, and it is TypeSafe's own —
|
|
313
|
+
* so the provider this whole file exists to talk to was the one provider whose
|
|
314
|
+
* explanation never reached a screen.
|
|
315
|
+
*
|
|
316
|
+
* `{error:"<code>", message:"…"}` is FailproofAI Cloud's own error body (its
|
|
317
|
+
* Jev route's contract, `{"error":"forbidden","message":"… jev:evaluate …"}`).
|
|
318
|
+
* Read as `{error:"…"}` alone it reported the bare word `forbidden` and dropped
|
|
319
|
+
* the sentence naming the missing permission, so when both are strings both
|
|
320
|
+
* are kept, code first.
|
|
321
|
+
*/
|
|
322
|
+
/**
|
|
323
|
+
* Provider text with its control characters (C0, DEL, C1) replaced by spaces.
|
|
324
|
+
* Error text is printed to a terminal, where OSC 52 writes the clipboard, OSC 8
|
|
325
|
+
* plants a link and ESC[2J erases the real diagnostic. Every field it is used
|
|
326
|
+
* for is one line.
|
|
327
|
+
*/
|
|
328
|
+
export function printable(s: string): string {
|
|
329
|
+
return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function providerErrorDetail(body: unknown, secret: string): string {
|
|
205
333
|
const b = body as {
|
|
206
334
|
errors?: Array<{ message?: unknown }>;
|
|
207
335
|
error?: unknown;
|
|
@@ -213,15 +341,24 @@ function errorDetail(body: unknown, secret: string): string {
|
|
|
213
341
|
if (Array.isArray(b.errors)) {
|
|
214
342
|
detail = b.errors.map((e) => (typeof e?.message === "string" ? e.message : "")).filter(Boolean).join("; ");
|
|
215
343
|
}
|
|
216
|
-
if (!detail && b.error && typeof b.
|
|
217
|
-
|
|
218
|
-
if (typeof m === "string") detail = m;
|
|
344
|
+
if (!detail && typeof b.error === "string" && b.error && typeof b.message === "string" && b.message) {
|
|
345
|
+
detail = `${b.error}: ${b.message}`;
|
|
219
346
|
}
|
|
220
|
-
if (!detail
|
|
347
|
+
if (!detail) detail = messageOf(b.error);
|
|
221
348
|
if (!detail && typeof b.message === "string") detail = b.message;
|
|
222
|
-
if (!detail
|
|
349
|
+
if (!detail) detail = messageOf(b.detail);
|
|
223
350
|
}
|
|
224
|
-
return scrubSecret(detail, secret).slice(0, MAX_ERROR_DETAIL);
|
|
351
|
+
return printable(scrubSecret(detail, secret)).slice(0, MAX_ERROR_DETAIL);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** An envelope member that is either the sentence itself or an object carrying it. */
|
|
355
|
+
function messageOf(value: unknown): string {
|
|
356
|
+
if (typeof value === "string") return value;
|
|
357
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
358
|
+
const m = (value as { message?: unknown }).message;
|
|
359
|
+
if (typeof m === "string") return m;
|
|
360
|
+
}
|
|
361
|
+
return "";
|
|
225
362
|
}
|
|
226
363
|
|
|
227
364
|
/**
|
|
@@ -279,12 +416,51 @@ function paymentRequiredCode(detail: string): string {
|
|
|
279
416
|
return MODEL_EXECUTION_402_RE.test(detail) ? JEV_REASON_PROVIDER_REFUSED : "out-of-credits";
|
|
280
417
|
}
|
|
281
418
|
|
|
419
|
+
/** Whether this URL is one THIS file built by appending its own suffix (see `nativeEndpoint`). */
|
|
420
|
+
function isSystemOneUrl(url: string): boolean {
|
|
421
|
+
try {
|
|
422
|
+
return /\/systemone$/i.test(new URL(url).pathname);
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The message for a failed status, which is the provider's own sentence when it
|
|
430
|
+
* sent one — except for a 404 on `<base>/systemone`, which needs saying rather
|
|
431
|
+
* than repeating.
|
|
432
|
+
*
|
|
433
|
+
* `/systemone` is appended here, not typed by the customer, and every provider
|
|
434
|
+
* serves it at its version root: measured 2026-09-25, an unauthenticated POST to
|
|
435
|
+
* that path answers 403 / 401 / 400 / 401 on TypeSafe, OpenRouter, Vercel and a
|
|
436
|
+
* LiteLLM proxy, and 404 on none of them. So a 404 there is near-certain evidence
|
|
437
|
+
* that the BASE is wrong — and the body is no help, because the one seen in the
|
|
438
|
+
* field said `Not Found` and nothing else. The URL is named because it is the
|
|
439
|
+
* constructed one, which is what the person has to compare against what they
|
|
440
|
+
* typed; it goes through `displayEndpoint`, so a base carrying a token in its
|
|
441
|
+
* query string does not put it in an error message.
|
|
442
|
+
*/
|
|
443
|
+
function httpFailureMessage(status: number, url: string, detail: string, derivedBase = false): string {
|
|
444
|
+
// A base URL the connection supplied is nobody's typo: a Cloud 404 is a
|
|
445
|
+
// server without the route, which `jev test`'s remedy line says.
|
|
446
|
+
if (status === 404 && derivedBase) return `nothing is served at ${displayEndpoint(url)}${detail ? ` (${detail})` : ""}`;
|
|
447
|
+
if (status === 404 && isSystemOneUrl(url)) {
|
|
448
|
+
// No `HTTP 404:` prefix: every caller prints the code beside the message.
|
|
449
|
+
return (
|
|
450
|
+
`nothing is served at ${displayEndpoint(url)}${detail ? ` (${detail})` : ""}. ` +
|
|
451
|
+
"`/systemone` is appended to the base URL you configured, and every Jev route serves it at the provider's version root — " +
|
|
452
|
+
"so this is a base URL that is wrong, not a provider that is down."
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return detail || `HTTP ${status}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
282
458
|
/** A redirect, including the opaque form a browser-style fetch returns for `redirect: "manual"` (status 0). */
|
|
283
459
|
function isRedirect(res: Response): boolean {
|
|
284
460
|
return res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400);
|
|
285
461
|
}
|
|
286
462
|
|
|
287
|
-
async function postJson(url: string, bearer: string, body: unknown, signal: AbortSignal): Promise<unknown> {
|
|
463
|
+
async function postJson(url: string, bearer: string, body: unknown, signal: AbortSignal, derivedBase = false): Promise<unknown> {
|
|
288
464
|
let res: Response;
|
|
289
465
|
try {
|
|
290
466
|
res = await fetch(url, {
|
|
@@ -311,6 +487,8 @@ async function postJson(url: string, bearer: string, body: unknown, signal: Abor
|
|
|
311
487
|
const status = res.status >= 300 && res.status < 400 ? String(res.status) : "3xx";
|
|
312
488
|
throw new JevError(`http-${status}`, `HTTP ${status}: the endpoint answered with a redirect, which is never followed`);
|
|
313
489
|
}
|
|
490
|
+
// Kept for a 429 and a 503: the statuses whose header says when to ask again.
|
|
491
|
+
const retryAfter = res.status === 429 || res.status === 503 ? res.headers.get("retry-after") : null;
|
|
314
492
|
let parsed: unknown;
|
|
315
493
|
try {
|
|
316
494
|
parsed = await res.json();
|
|
@@ -319,18 +497,20 @@ async function postJson(url: string, bearer: string, body: unknown, signal: Abor
|
|
|
319
497
|
// No provider words to read, so nothing distinguishes a refusal from an
|
|
320
498
|
// empty account: the status's own meaning stands (see `paymentRequiredCode`).
|
|
321
499
|
if (res.status === 402) throw new JevError("out-of-credits", "HTTP 402: the account is out of credits");
|
|
322
|
-
|
|
500
|
+
// A 404 needs no body to be diagnosed, and the ones seen in the field carry
|
|
501
|
+
// none worth reading: the URL is the diagnosis (see `httpFailureMessage`).
|
|
502
|
+
if (!res.ok) throw new JevError(`http-${res.status}`, httpFailureMessage(res.status, url, "", derivedBase), { retryAfter });
|
|
323
503
|
throw new JevError("malformed", "response body is not JSON");
|
|
324
504
|
}
|
|
325
505
|
if (res.status === 402) {
|
|
326
506
|
// A refusal is only ever recognised FROM the provider's words, so a 402
|
|
327
507
|
// with none is `out-of-credits` and this message fits it. Anything the
|
|
328
508
|
// provider did say is the message, refusal or not.
|
|
329
|
-
const detail =
|
|
509
|
+
const detail = providerErrorDetail(parsed, bearer);
|
|
330
510
|
throw new JevError(paymentRequiredCode(detail), detail || "HTTP 402: the account is out of credits");
|
|
331
511
|
}
|
|
332
512
|
if (!res.ok) {
|
|
333
|
-
throw new JevError(`http-${res.status}`,
|
|
513
|
+
throw new JevError(`http-${res.status}`, httpFailureMessage(res.status, url, providerErrorDetail(parsed, bearer), derivedBase), { retryAfter });
|
|
334
514
|
}
|
|
335
515
|
return parsed;
|
|
336
516
|
}
|
|
@@ -354,6 +534,8 @@ export interface NativeTransportOptions {
|
|
|
354
534
|
aliases?: readonly string[];
|
|
355
535
|
/** Whether an answer with no `model` at all is accepted (as unverified) rather than refused. */
|
|
356
536
|
allowUnreported?: boolean;
|
|
537
|
+
/** The base URL came from a connection, not from the person (FailproofAI Cloud). */
|
|
538
|
+
derivedBase?: boolean;
|
|
357
539
|
}
|
|
358
540
|
|
|
359
541
|
/**
|
|
@@ -368,7 +550,7 @@ function normalizeNative(body: unknown, sentModel: string, opts: NativeTransport
|
|
|
368
550
|
// Gateways sometimes report an upstream failure inside a 200.
|
|
369
551
|
if (b.error !== undefined) {
|
|
370
552
|
const code = typeof b.error === "object" && b.error !== null ? (b.error as { code?: unknown }).code : undefined;
|
|
371
|
-
const detail =
|
|
553
|
+
const detail = providerErrorDetail(body, opts.apiKey) || "the provider reported an error";
|
|
372
554
|
// Same two meanings, one layer in; same rule (see `paymentRequiredCode`).
|
|
373
555
|
if (code === 402 || code === "402") throw new JevError(paymentRequiredCode(detail), detail);
|
|
374
556
|
if (typeof code === "number" && (code === 429 || code >= 500)) throw new JevError(`http-${code}`, detail);
|
|
@@ -401,7 +583,7 @@ export function nativeTransport(opts: NativeTransportOptions): JevTransport {
|
|
|
401
583
|
return async (request, signal) => {
|
|
402
584
|
const model = opts.model ?? request.model;
|
|
403
585
|
const body = { ...request, model, ...(opts.extraBody ?? {}) };
|
|
404
|
-
return normalizeNative(await postJson(opts.url, opts.apiKey, body, signal), model, opts);
|
|
586
|
+
return normalizeNative(await postJson(opts.url, opts.apiKey, body, signal, opts.derivedBase), model, opts);
|
|
405
587
|
};
|
|
406
588
|
}
|
|
407
589
|
|
|
@@ -536,14 +718,19 @@ export interface JevRoute {
|
|
|
536
718
|
modelIsDefault: boolean;
|
|
537
719
|
}
|
|
538
720
|
|
|
539
|
-
function nativeEndpoint(baseUrl: string): string {
|
|
721
|
+
export function nativeEndpoint(baseUrl: string): string {
|
|
540
722
|
const url = new URL(baseUrl);
|
|
541
723
|
if (!/\/systemone$/.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/+$/, "")}/systemone`;
|
|
542
724
|
return url.toString();
|
|
543
725
|
}
|
|
544
726
|
|
|
727
|
+
/**
|
|
728
|
+
* A config as the loader produced it, validated again. `validateLoadedJevConfig`
|
|
729
|
+
* rather than `validateJevConfig`, because the FailproofAI Cloud provider's key
|
|
730
|
+
* is filled in from `credentials.json` and would be refused as a file field.
|
|
731
|
+
*/
|
|
545
732
|
function validated(cfg: JevConfig): JevConfig {
|
|
546
|
-
const v =
|
|
733
|
+
const v = validateLoadedJevConfig(cfg);
|
|
547
734
|
if (!v.ok) throw new JevError("config", v.problem);
|
|
548
735
|
return v.value;
|
|
549
736
|
}
|
|
@@ -558,7 +745,7 @@ export function jevRoute(input: JevConfig): JevRoute {
|
|
|
558
745
|
return { via: "cloudflare", endpoint: cloudflareEndpoint(cfg.accountId as string, cfg.baseUrl), model, modelIsDefault };
|
|
559
746
|
}
|
|
560
747
|
const base = cfg.baseUrl ?? defaults.baseUrl;
|
|
561
|
-
if (!base) throw new JevError("config",
|
|
748
|
+
if (!base) throw new JevError("config", `provider ${cfg.provider} needs a baseUrl`);
|
|
562
749
|
return { via: cfg.provider, endpoint: nativeEndpoint(base), model, modelIsDefault };
|
|
563
750
|
}
|
|
564
751
|
|
|
@@ -573,6 +760,311 @@ export function displayEndpoint(endpoint: string): string {
|
|
|
573
760
|
}
|
|
574
761
|
}
|
|
575
762
|
|
|
763
|
+
/**
|
|
764
|
+
* Why a URL given where a base belongs is refused, in one sentence shared by
|
|
765
|
+
* `jev setup` and the dashboard's save, built from the transport's own URL so
|
|
766
|
+
* neither describes a request it does not make: a `/systemone` URL is used as
|
|
767
|
+
* it is (never doubled); any other would be asked at `<url>/systemone`.
|
|
768
|
+
*/
|
|
769
|
+
export function endpointAsBaseReason(given: string): string {
|
|
770
|
+
const asked = nativeEndpoint(given);
|
|
771
|
+
return asked === new URL(given).toString()
|
|
772
|
+
? "That is already the Jev endpoint itself: this field takes the base it sits under (where <base>/models is read), and failproofai adds /systemone on its own."
|
|
773
|
+
: `A Jev request goes to <base>/systemone, and failproofai appends that itself, so ${displayEndpoint(given)} would be asked at ${displayEndpoint(asked)}.`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── The model list ───────────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Which envelope a model list arrived in. The difference decides what may be
|
|
780
|
+
* DONE with it, not just how it is parsed — see the header.
|
|
781
|
+
*/
|
|
782
|
+
export type JevModelListShape =
|
|
783
|
+
/** `{models:[{name, description?, release_date?}]}` — the System One inventory of this base. */
|
|
784
|
+
| "typesafe"
|
|
785
|
+
/** `{data:[{id, name?}]}` — a gateway's chat catalog, which does not enumerate `/systemone`. */
|
|
786
|
+
| "openai";
|
|
787
|
+
|
|
788
|
+
export interface JevModelListRead {
|
|
789
|
+
ok: true;
|
|
790
|
+
shape: JevModelListShape;
|
|
791
|
+
/** Model ids, in the order the provider gave them, deduplicated. */
|
|
792
|
+
models: string[];
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export interface JevModelListUnread {
|
|
796
|
+
ok: false;
|
|
797
|
+
/** Why, in words meant for a person. Never a code: nothing stores or branches on this. */
|
|
798
|
+
reason: string;
|
|
799
|
+
/** The HTTP status, when the read got that far. */
|
|
800
|
+
status?: number;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export type JevModelListResult = JevModelListRead | JevModelListUnread;
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* A model list read is a diagnostic, and the whole point of it is to be
|
|
807
|
+
* available BEFORE a config is written — so it gets its own budget rather than
|
|
808
|
+
* the config's `timeoutMs`, which is sized for the hook path (3000 ms, and every
|
|
809
|
+
* millisecond of it lands on a tool call).
|
|
810
|
+
*/
|
|
811
|
+
export const JEV_MODEL_LIST_TIMEOUT_MS = 2_500;
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Names kept from one list. OpenRouter's catalog is 458 entries and a proxy's
|
|
815
|
+
* could be anything; this is a terminal, and a bound here is cheaper than a
|
|
816
|
+
* bound at every place that prints one.
|
|
817
|
+
*/
|
|
818
|
+
const MAX_LISTED_MODELS = 200;
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* `<base>/models`, or null where there is no such thing.
|
|
822
|
+
*
|
|
823
|
+
* Cloudflare is the null: Workers AI has no `<base>/models` — the run endpoint is
|
|
824
|
+
* `/accounts/<id>/ai/run` and its inventory lives behind a different API — so
|
|
825
|
+
* nothing here can read a list for it, and every caller treats that exactly like
|
|
826
|
+
* a list it failed to read.
|
|
827
|
+
*
|
|
828
|
+
* A base that names the full request URL (`…/v1/systemone`, which `jev setup`
|
|
829
|
+
* now refuses but an older file may carry) still has its version root one
|
|
830
|
+
* segment up, and that is where the list is.
|
|
831
|
+
*/
|
|
832
|
+
export function jevModelsUrl(input: JevConfig): string | null {
|
|
833
|
+
const cfg = validated(input);
|
|
834
|
+
// FailproofAI Cloud pins the model server-side and serves no list.
|
|
835
|
+
if (cfg.provider === "cloudflare" || cfg.provider === "failproofai") return null;
|
|
836
|
+
const base = cfg.baseUrl ?? JEV_PROVIDER_DEFAULTS[cfg.provider].baseUrl;
|
|
837
|
+
return base ? modelsUrlForBase(base) : null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** `<base>/models` for a base URL, keeping any query string the base carried. */
|
|
841
|
+
export function modelsUrlForBase(base: string): string | null {
|
|
842
|
+
try {
|
|
843
|
+
const url = new URL(base);
|
|
844
|
+
url.pathname = `${url.pathname.replace(/\/systemone$/i, "").replace(/\/+$/, "")}/models`;
|
|
845
|
+
return url.toString();
|
|
846
|
+
} catch {
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The model ids in a list body, or why it could not be read. Never throws: a
|
|
853
|
+
* customer's proxy may serve neither shape, or an HTML error page, and an
|
|
854
|
+
* unreadable list must leave every caller exactly where it was.
|
|
855
|
+
*
|
|
856
|
+
* `secret` is the key the list was fetched with. Names are scrubbed of it and
|
|
857
|
+
* then kept only if they are SHAPED like model ids, because these strings come
|
|
858
|
+
* from a remote endpoint and are about to be printed on a terminal — a name
|
|
859
|
+
* carrying control characters, or a kilobyte of them, is not a name.
|
|
860
|
+
*
|
|
861
|
+
* A shape that is present but yields no usable name is reported as unread rather
|
|
862
|
+
* than as "this endpoint has no models": an empty list is far likelier to be a
|
|
863
|
+
* shape misread than a provider serving nothing, and "unread" is the direction
|
|
864
|
+
* that changes no behaviour.
|
|
865
|
+
*/
|
|
866
|
+
export function parseJevModelList(body: unknown, secret = ""): JevModelListResult {
|
|
867
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
868
|
+
return { ok: false, reason: "the list endpoint did not answer with a JSON object" };
|
|
869
|
+
}
|
|
870
|
+
const b = body as { models?: unknown; data?: unknown };
|
|
871
|
+
for (const [shape, entries, keys] of [
|
|
872
|
+
["typesafe", b.models, ["name"]],
|
|
873
|
+
["openai", b.data, ["id", "name"]],
|
|
874
|
+
] as Array<[JevModelListShape, unknown, string[]]>) {
|
|
875
|
+
if (!Array.isArray(entries)) continue;
|
|
876
|
+
const models = listedModelNames(entries, keys, secret);
|
|
877
|
+
return models.length > 0 ? { ok: true, shape, models } : { ok: false, reason: "the list named no models this build could read" };
|
|
878
|
+
}
|
|
879
|
+
return { ok: false, reason: "the list endpoint answered in a shape this build does not know (neither {models:[…]} nor {data:[…]})" };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function listedModelNames(entries: unknown[], keys: string[], secret: string): string[] {
|
|
883
|
+
const out: string[] = [];
|
|
884
|
+
for (const entry of entries) {
|
|
885
|
+
if (!entry || typeof entry !== "object") continue;
|
|
886
|
+
const row = entry as Record<string, unknown>;
|
|
887
|
+
for (const key of keys) {
|
|
888
|
+
const raw = row[key];
|
|
889
|
+
if (typeof raw !== "string" || raw === "") continue;
|
|
890
|
+
const name = scrubSecret(raw, secret);
|
|
891
|
+
if (isModelIdShaped(name) && !out.includes(name)) out.push(name);
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
if (out.length >= MAX_LISTED_MODELS) break;
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* `GET <base>/models`. Returns a reading or a reason, never a throw, because
|
|
901
|
+
* every caller's fallback is to carry on as if the endpoint served no list.
|
|
902
|
+
*
|
|
903
|
+
* `apiKey` may be null: Vercel's and OpenRouter's lists are public, and reading
|
|
904
|
+
* one with no key is better than not reading it. Redirects are not followed, for
|
|
905
|
+
* the reason `postJson` gives — a list read is less dangerous than an answer, but
|
|
906
|
+
* an endpoint that moves this GET somewhere unchecked has not earned the key that
|
|
907
|
+
* would ride along with it.
|
|
908
|
+
*/
|
|
909
|
+
export async function readJevModelList(url: string, apiKey: string | null, signal: AbortSignal): Promise<JevModelListResult> {
|
|
910
|
+
const secret = apiKey ?? "";
|
|
911
|
+
let res: Response;
|
|
912
|
+
try {
|
|
913
|
+
res = await fetch(url, {
|
|
914
|
+
method: "GET",
|
|
915
|
+
headers: { Accept: "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
|
|
916
|
+
signal,
|
|
917
|
+
redirect: "manual",
|
|
918
|
+
});
|
|
919
|
+
} catch (err) {
|
|
920
|
+
if (signal.aborted) return { ok: false, reason: `it did not answer within ${JEV_MODEL_LIST_TIMEOUT_MS} ms` };
|
|
921
|
+
return { ok: false, reason: printable(scrubSecret(err instanceof Error ? err.message : String(err), secret)).slice(0, MAX_ERROR_DETAIL) };
|
|
922
|
+
}
|
|
923
|
+
if (isRedirect(res)) {
|
|
924
|
+
try {
|
|
925
|
+
void res.body?.cancel().catch(() => {});
|
|
926
|
+
} catch {
|
|
927
|
+
// The body is irrelevant; freeing it is best effort.
|
|
928
|
+
}
|
|
929
|
+
return { ok: false, reason: "it answered with a redirect, which is never followed" };
|
|
930
|
+
}
|
|
931
|
+
let parsed: unknown;
|
|
932
|
+
try {
|
|
933
|
+
parsed = await res.json();
|
|
934
|
+
} catch {
|
|
935
|
+
if (signal.aborted) return { ok: false, reason: `it did not answer within ${JEV_MODEL_LIST_TIMEOUT_MS} ms` };
|
|
936
|
+
return { ok: false, reason: `HTTP ${res.status}: it answered with no JSON`, status: res.status };
|
|
937
|
+
}
|
|
938
|
+
if (!res.ok) {
|
|
939
|
+
// The provider's own sentence, from whichever envelope — which for a list
|
|
940
|
+
// read is usually "Must supply an API key!", and is the answer.
|
|
941
|
+
const detail = providerErrorDetail(parsed, secret);
|
|
942
|
+
return { ok: false, reason: detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`, status: res.status };
|
|
943
|
+
}
|
|
944
|
+
return parseJevModelList(parsed, secret);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Whether a reading is authoritative about what `<base>/systemone` accepts, and
|
|
949
|
+
* so may be used to REFUSE a model rather than only to show one.
|
|
950
|
+
*
|
|
951
|
+
* Only the TypeSafe shape is. The evidence is in the header: OpenRouter serves an
|
|
952
|
+
* OpenAI-shaped catalog of 458 models with no Jev entry in it, while its
|
|
953
|
+
* `/systemone` exists and is addressed as `typesafe/jev-1.13` — so refusing on an
|
|
954
|
+
* OpenAI-shaped list would refuse a route that works.
|
|
955
|
+
*/
|
|
956
|
+
export function listDescribesSystemOne(list: JevModelListRead): boolean {
|
|
957
|
+
return list.shape === "typesafe";
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Whether a list covers a configured model id.
|
|
962
|
+
*
|
|
963
|
+
* Exact match, or the id without an `<owner>/` prefix. That second case is
|
|
964
|
+
* measured, not a convenience: Vercel names one model `typesafe-ai/jev` in its
|
|
965
|
+
* gateway catalog and `jev` in the typesafe-scoped list at the base we POST to,
|
|
966
|
+
* differing by exactly that prefix (see the header).
|
|
967
|
+
*
|
|
968
|
+
* A versioned id (`jev-1.13.0`) always counts: a list names aliases, and cannot
|
|
969
|
+
* prove one absent (see the header).
|
|
970
|
+
*/
|
|
971
|
+
export function modelListHasModel(list: JevModelListRead, model: string): boolean {
|
|
972
|
+
if (jevModelVersion(model) !== null) return true;
|
|
973
|
+
if (list.models.includes(model)) return true;
|
|
974
|
+
const slash = model.lastIndexOf("/");
|
|
975
|
+
return slash > 0 && list.models.includes(model.slice(slash + 1));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ── FailproofAI Cloud: Retry-After ───────────────────────────────────────────
|
|
979
|
+
//
|
|
980
|
+
// FailproofAI Cloud rate-limits Jev per org and globally, and its 429 carries
|
|
981
|
+
// `Retry-After: <seconds to the window's end>` (contract §2). Every call that
|
|
982
|
+
// ignores it spends a round trip — up to the hook's whole timeout, on the tool
|
|
983
|
+
// call's critical path — to learn the same 429, and adds to the load that
|
|
984
|
+
// caused it. So after a 429 the Cloud route goes quiet for as long as the
|
|
985
|
+
// server asked: calls fall back at once with the same `http-429`, and send
|
|
986
|
+
// nothing.
|
|
987
|
+
//
|
|
988
|
+
// Module-level, like the throttle's cache and bucket (`jev-throttle.ts`): the
|
|
989
|
+
// daemon's warm worker builds a new transport per hook event and lives for
|
|
990
|
+
// hours, so the process is what has to remember. Keyed by the endpoint, so a
|
|
991
|
+
// reconnect to another Cloud is not held to the old one's window.
|
|
992
|
+
//
|
|
993
|
+
// The Cloud route only. A BYOK provider's 429 keeps doing exactly what it did
|
|
994
|
+
// (the throttle empties its bucket), because nothing here knows what its
|
|
995
|
+
// `Retry-After` means or whether it sends one.
|
|
996
|
+
|
|
997
|
+
/** The longest cool-down a Retry-After can set: a hook never goes quiet for longer on the server's say-so. */
|
|
998
|
+
export const JEV_CLOUD_RETRY_AFTER_CAP_MS = 60_000;
|
|
999
|
+
/** The cool-down after a 429 whose Retry-After is missing or unreadable. */
|
|
1000
|
+
export const JEV_CLOUD_RETRY_AFTER_DEFAULT_MS = 5_000;
|
|
1001
|
+
|
|
1002
|
+
const cloudCooldown = { endpoint: "", until: Number.NEGATIVE_INFINITY, code: "http-429" };
|
|
1003
|
+
|
|
1004
|
+
/** Forget any cool-down. For tests, which share this module's state within a file. */
|
|
1005
|
+
export function resetJevCloudCooldown(): void {
|
|
1006
|
+
cloudCooldown.endpoint = "";
|
|
1007
|
+
cloudCooldown.until = Number.NEGATIVE_INFINITY;
|
|
1008
|
+
cloudCooldown.code = "http-429";
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** RFC 9110's IMF-fixdate, the one HTTP-date form a sender generates: `Sun, 06 Nov 1994 08:49:37 GMT`. */
|
|
1012
|
+
const IMF_FIXDATE_RE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* How long a `Retry-After` asks for, in ms, within [0, cap]. Delay-seconds
|
|
1016
|
+
* (what FailproofAI Cloud sends) or an IMF-fixdate; anything else — absent,
|
|
1017
|
+
* negative, fractional, garbage — is the small default. Matched by shape
|
|
1018
|
+
* before `Date.parse` sees it, because that parser reads `-5` or `1.5` as a
|
|
1019
|
+
* date in the past, which would be no cool-down at all.
|
|
1020
|
+
*/
|
|
1021
|
+
export function retryAfterMs(header: string | null | undefined, nowEpochMs: number = Date.now()): number {
|
|
1022
|
+
const raw = header?.trim() ?? "";
|
|
1023
|
+
let ms: number;
|
|
1024
|
+
if (/^\d{1,10}$/.test(raw)) {
|
|
1025
|
+
ms = Number(raw) * 1000;
|
|
1026
|
+
} else if (IMF_FIXDATE_RE.test(raw) && Number.isFinite(Date.parse(raw))) {
|
|
1027
|
+
ms = Date.parse(raw) - nowEpochMs;
|
|
1028
|
+
} else {
|
|
1029
|
+
return JEV_CLOUD_RETRY_AFTER_DEFAULT_MS;
|
|
1030
|
+
}
|
|
1031
|
+
return Math.min(JEV_CLOUD_RETRY_AFTER_CAP_MS, Math.max(0, ms));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* The Cloud transport, quiet for as long as the last 429's Retry-After asked —
|
|
1036
|
+
* and after a 503 too. The server's 503 is an operator state (no model
|
|
1037
|
+
* gateway, an org not provisioned yet, the gateway down) that waiting does not
|
|
1038
|
+
* fix, so without a Retry-After it holds the longest this ever does, and a held
|
|
1039
|
+
* call keeps the code that started it rather than reading as a rate limit.
|
|
1040
|
+
*/
|
|
1041
|
+
function cloudRetryAfter(endpoint: string, transport: JevTransport): JevTransport {
|
|
1042
|
+
return async (request, signal) => {
|
|
1043
|
+
const now = performance.now();
|
|
1044
|
+
if (cloudCooldown.endpoint === endpoint && now < cloudCooldown.until) {
|
|
1045
|
+
const seconds = Math.max(1, Math.ceil((cloudCooldown.until - now) / 1000));
|
|
1046
|
+
throw new JevError(
|
|
1047
|
+
cloudCooldown.code,
|
|
1048
|
+
cloudCooldown.code === "http-503"
|
|
1049
|
+
? `FailproofAI Cloud could not serve Jev; asking again in ${seconds}s, so this one was not sent`
|
|
1050
|
+
: `FailproofAI Cloud asked for no Jev requests for ${seconds}s more (Retry-After), so this one was not sent`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
return await transport(request, signal);
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
if (err instanceof JevError && (err.code === "http-429" || err.code === "http-503")) {
|
|
1057
|
+
cloudCooldown.endpoint = endpoint;
|
|
1058
|
+
cloudCooldown.code = err.code;
|
|
1059
|
+
cloudCooldown.until =
|
|
1060
|
+
performance.now() +
|
|
1061
|
+
(err.code === "http-503" && err.retryAfter === null ? JEV_CLOUD_RETRY_AFTER_CAP_MS : retryAfterMs(err.retryAfter));
|
|
1062
|
+
}
|
|
1063
|
+
throw err;
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
576
1068
|
/**
|
|
577
1069
|
* The transport for a customer's own Jev config, which provider it goes
|
|
578
1070
|
* through, and the model id to put in the request (pass it to the evaluator as
|
|
@@ -639,5 +1131,25 @@ export function transportForConfig(input: JevConfig): { transport: JevTransport;
|
|
|
639
1131
|
via: "custom",
|
|
640
1132
|
model: route.model,
|
|
641
1133
|
};
|
|
1134
|
+
case "failproofai":
|
|
1135
|
+
return {
|
|
1136
|
+
transport: cloudRetryAfter(
|
|
1137
|
+
route.endpoint,
|
|
1138
|
+
nativeTransport({
|
|
1139
|
+
url: route.endpoint,
|
|
1140
|
+
apiKey: cfg.apiKey,
|
|
1141
|
+
model: route.model,
|
|
1142
|
+
// FailproofAI Cloud forces `jev-1.13.0` server-side and passes
|
|
1143
|
+
// TypeSafe's answer through untouched, `model` included — so an
|
|
1144
|
+
// answer that names no model, or an alias, is not one this route
|
|
1145
|
+
// produces, and is refused (`model-mismatch`) rather than trusted.
|
|
1146
|
+
// `readAnswers` then holds the reported id to the 1.13 family.
|
|
1147
|
+
allowUnreported: false,
|
|
1148
|
+
derivedBase: true,
|
|
1149
|
+
}),
|
|
1150
|
+
),
|
|
1151
|
+
via: "failproofai",
|
|
1152
|
+
model: route.model,
|
|
1153
|
+
};
|
|
642
1154
|
}
|
|
643
1155
|
}
|