@desplega.ai/agent-swarm 2.4.3 → 2.4.5

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 (270) hide show
  1. package/dist/{acp-adapter-1885dpr7.js → acp-adapter-2wdkktv6.js} +1 -2
  2. package/dist/{actions-pn76n32q.js → actions-c40ebcm4.js} +18 -18
  3. package/dist/{amp-adapter-af84rx1d.js → amp-adapter-wb67m95z.js} +2 -2
  4. package/dist/{app-6sv8mhzm.js → app-353vre8g.js} +3 -3
  5. package/dist/{assistant-sc2tf825.js → assistant-sj1njh23.js} +21 -21
  6. package/dist/{boot-reembed-y3m0ydzj.js → boot-reembed-b9c3az3c.js} +1 -1
  7. package/dist/{boot-reembed-gvxjs2py.js → boot-reembed-qdkvxeem.js} +1 -1
  8. package/dist/{boot-scrub-sweep-1j9mk05f.js → boot-scrub-sweep-m3wh8eks.js} +2 -2
  9. package/dist/{boot-seeds-37sp626b.js → boot-seeds-j2273s54.js} +4 -4
  10. package/dist/{claude-adapter-rp0jtnwr.js → claude-adapter-m7q0a085.js} +2 -2
  11. package/dist/{claude-managed-adapter-h2y889ge.js → claude-managed-adapter-5ptb6tzs.js} +2 -2
  12. package/dist/{claude-managed-setup-rdt248k7.js → claude-managed-setup-smrqk5j5.js} +3 -3
  13. package/dist/{claude-sdk-session-53rcw5we.js → claude-sdk-session-38xnz21e.js} +8284 -8267
  14. package/dist/{cli-6eyncg0y.js → cli-018r4jq6.js} +34 -6
  15. package/dist/{cli-sds5zzs8.js → cli-1f3qzw6y.js} +78 -12
  16. package/dist/{cli-f0p0f6ja.js → cli-1sp861s4.js} +7 -7
  17. package/dist/{cli-dm8p2dpf.js → cli-1syn2xy1.js} +10 -10
  18. package/dist/{cli-tkf4x6ha.js → cli-37j04k05.js} +11 -11
  19. package/dist/{cli-v87dr6e3.js → cli-3a45zcgm.js} +1 -1
  20. package/dist/{cli-1n9gdg56.js → cli-43x64hv3.js} +4 -4
  21. package/dist/{cli-p0qerd2r.js → cli-4zfqgjc5.js} +2 -2
  22. package/dist/{cli-aa3tb29r.js → cli-7c9qs530.js} +1 -1
  23. package/dist/{cli-9qtjfd60.js → cli-848hxdwj.js} +1 -1
  24. package/dist/{cli-1xsraagq.js → cli-94gwyg7m.js} +8 -8
  25. package/dist/{cli-0dxxwfkt.js → cli-9hstj09h.js} +17 -14
  26. package/dist/{cli-1012xb7j.js → cli-9yqgm9nd.js} +22 -22
  27. package/dist/{cli-pygdsgh8.js → cli-a92r1127.js} +1 -1
  28. package/dist/{cli-8vverzf7.js → cli-ba8ne1es.js} +5 -0
  29. package/dist/{cli-bxrqvpg5.js → cli-be2161h1.js} +7 -7
  30. package/dist/{cli-npfe9t1s.js → cli-bnes4z6b.js} +2 -2
  31. package/dist/{cli-aypbg9r2.js → cli-cxxe61f6.js} +1 -1
  32. package/dist/{cli-vq9gqarq.js → cli-dk44dxec.js} +1 -1
  33. package/dist/{cli-836ehxmt.js → cli-eww120g8.js} +3 -2
  34. package/dist/{cli-a0kcty51.js → cli-f01h25yt.js} +1 -1
  35. package/dist/{cli-mzkwkbdy.js → cli-f0g3pgvf.js} +1 -1
  36. package/dist/{cli-1ma335fz.js → cli-ftc1g5tj.js} +2 -2
  37. package/dist/{cli-4yef8ghv.js → cli-fv5hepyy.js} +3 -3
  38. package/dist/{cli-58vwn100.js → cli-g35m2xwm.js} +4 -4
  39. package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
  40. package/dist/{cli-gx0j2khs.js → cli-j99azg3g.js} +3 -3
  41. package/dist/{cli-er83cjbn.js → cli-k8g16gz3.js} +416 -116
  42. package/dist/{cli-qp2m4btv.js → cli-kpw04va0.js} +2 -2
  43. package/dist/{cli-8y99kfey.js → cli-ms85309d.js} +33 -15
  44. package/dist/{cli-kgnkyabk.js → cli-n72rg53p.js} +1555 -645
  45. package/dist/{cli-e4d78xsv.js → cli-p14m2cnp.js} +1 -1
  46. package/dist/{cli-1eszr343.js → cli-qfxc84gh.js} +1 -1
  47. package/dist/{cli-wk28hzqn.js → cli-qwhy2qma.js} +1 -1
  48. package/dist/{cli-73ezfhha.js → cli-st8aw1m6.js} +228 -11
  49. package/dist/{cli-zvbb4n0e.js → cli-tw4p90jb.js} +92 -192
  50. package/dist/{cli-038fga5k.js → cli-v0yrfmdh.js} +148 -183
  51. package/dist/{cli-6aen3ezb.js → cli-v8fjakwf.js} +1 -1
  52. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  53. package/dist/{cli-vcgscaan.js → cli-wt0jf9hm.js} +217 -80
  54. package/dist/{cli-s9rr4gr2.js → cli-xpa64rek.js} +1065 -305
  55. package/dist/{cli-h0mgxann.js → cli-z7d29rw9.js} +2 -2
  56. package/dist/cli.js +45 -20
  57. package/dist/{codex-adapter-2zrmab9b.js → codex-adapter-tn6k5mkg.js} +1 -1
  58. package/dist/{codex-session-runner-kbkz62vc.js → codex-session-runner-7vjt7jbw.js} +1 -1
  59. package/dist/{commands-hd574th6.js → commands-t7rz9146.js} +2 -2
  60. package/dist/{controller-pz5mcfg4.js → controller-m2jasma5.js} +2 -2
  61. package/dist/{controllers-connect-5rgq2hfk.js → controllers-connect-9ftpex99.js} +19 -19
  62. package/dist/{cursor-adapter-mgaprq0j.js → cursor-adapter-mjv79e4z.js} +1 -1
  63. package/dist/{db-6nf1207q.js → db-6sfrn9g3.js} +1 -1
  64. package/dist/{db-h06dxp05.js → db-rn673r06.js} +3 -3
  65. package/dist/{dispatcher-zthpy4m1.js → dispatcher-c155xz0p.js} +17 -17
  66. package/dist/{dsh-adapter-64dhw0j7.js → dsh-adapter-3mmszc1x.js} +1 -1
  67. package/dist/{external-channel-x01t7ke6.js → external-channel-35k6tkv8.js} +2 -2
  68. package/dist/{grok-adapter-h21jv594.js → grok-adapter-tfrq7tg1.js} +2 -3
  69. package/dist/handlers-7gw64dxw.js +39 -0
  70. package/dist/{heartbeat-zzfksckz.js → heartbeat-dyg4npkp.js} +20 -20
  71. package/dist/{hook-z4zhzj1f.js → hook-drspbwpe.js} +1 -1
  72. package/dist/{hook-3xfqhh2s.js → hook-x26b31bg.js} +1 -1
  73. package/dist/{http-q813tmya.js → http-pk972xyw.js} +571 -294
  74. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  75. package/dist/{index-x8xbyn1t.js → index-arytgj5q.js} +1 -1
  76. package/dist/{index-wbbs14c5.js → index-bgdyvcnv.js} +17 -17
  77. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  78. package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
  79. package/dist/{index-m0a4ydqj.js → index-gabbcdn2.js} +17 -17
  80. package/dist/{index-220tk9bw.js → index-jwxkkex2.js} +139 -76
  81. package/dist/index-tfb26zhy.js +41 -0
  82. package/dist/{keepalive-y5t1my7m.js → keepalive-7yrng7px.js} +3 -3
  83. package/dist/{lead-2xwtq66b.js → lead-7e02z839.js} +29 -30
  84. package/dist/{maintenance-ybfsaedv.js → maintenance-e6cemdqt.js} +1 -1
  85. package/dist/{node-3v3f08f7.js → node-de6akczr.js} +10 -9
  86. package/dist/{oauth-refresh-sweep-909jmn12.js → oauth-refresh-sweep-yw3xdbcj.js} +2 -2
  87. package/dist/{onboard-xn6fkkys.js → onboard-0a9y5yrc.js} +2 -2
  88. package/dist/{otel-impl-e6mn0z13.js → otel-impl-trjpkfb7.js} +1 -1
  89. package/dist/{pricing-refresh-ygd362ep.js → pricing-refresh-d2b5my0y.js} +3 -3
  90. package/dist/{rbac-roles-mb470p4e.js → rbac-roles-hey3yv9d.js} +2 -2
  91. package/dist/{rbac-roles-c9axvfnz.js → rbac-roles-nxh5ahwk.js} +2 -2
  92. package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
  93. package/dist/{render-v2-g0306nh1.js → render-v2-rr3p2atg.js} +3 -3
  94. package/dist/{resume-v315hnq6.js → resume-2efkf1s1.js} +5 -5
  95. package/dist/{session-access-2zg285a1.js → session-access-s2mttxpw.js} +6 -4
  96. package/dist/{setup-a4mtf02k.js → setup-vypma5yt.js} +1 -1
  97. package/dist/{work-objects-9fr0fgtn.js → work-objects-rvhckdkp.js} +20 -20
  98. package/dist/{worker-qae22jeb.js → worker-5snytpbd.js} +29 -30
  99. package/dist/worker-token-xf11yyds.js +136 -0
  100. package/openapi.json +303 -13
  101. package/package.json +8 -8
  102. package/src/apps/action-runner.ts +4 -0
  103. package/src/apps/catalog.generated.json +209 -4
  104. package/src/apps/page-validator.ts +21 -3
  105. package/src/be/chunking.ts +42 -9
  106. package/src/be/db/tasks/draft-text.ts +15 -0
  107. package/src/be/db/tasks/read.ts +14 -1
  108. package/src/be/db.ts +5 -1
  109. package/src/be/memory/index-content.ts +36 -17
  110. package/src/be/memory/providers/openai-embedding.ts +115 -61
  111. package/src/be/memory/providers/sqlite-store.ts +69 -23
  112. package/src/be/memory/reranker.ts +31 -1
  113. package/src/be/memory/search-candidates.ts +47 -0
  114. package/src/be/memory/types.ts +14 -0
  115. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  116. package/src/be/scripts/typecheck.ts +2 -0
  117. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +116 -55
  118. package/src/be/seed-scripts/index.ts +2 -2
  119. package/src/be/swarm-config-guard.ts +40 -1
  120. package/src/be/task-attachment-store.ts +30 -0
  121. package/src/be/users.ts +169 -12
  122. package/src/cli.tsx +24 -0
  123. package/src/commands/worker-token.ts +164 -0
  124. package/src/extensions/catalog.generated.json +4 -4
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/extensions/contract.ts +7 -0
  127. package/src/fs/provider.ts +13 -1
  128. package/src/heartbeat/heartbeat.ts +31 -11
  129. package/src/http/apps.ts +43 -5
  130. package/src/http/codex-oauth-keep-warm.ts +27 -9
  131. package/src/http/config.ts +5 -3
  132. package/src/http/core.ts +2 -0
  133. package/src/http/fs.ts +54 -34
  134. package/src/http/mcp-body.ts +37 -0
  135. package/src/http/mcp-user.ts +3 -5
  136. package/src/http/mcp.ts +6 -7
  137. package/src/http/memory.ts +26 -15
  138. package/src/http/page-proxy.ts +61 -5
  139. package/src/http/pages-public.ts +27 -44
  140. package/src/http/pages.ts +4 -4
  141. package/src/http/script-runs.ts +2 -0
  142. package/src/http/session-access.ts +51 -2
  143. package/src/http/sessions.ts +249 -10
  144. package/src/http/tasks.ts +4 -32
  145. package/src/http/users.ts +12 -1
  146. package/src/integrations/chat-sdk/adapters.ts +7 -3
  147. package/src/integrations/chat-sdk/attachments.ts +90 -12
  148. package/src/integrations/chat-sdk/facts.ts +120 -0
  149. package/src/integrations/chat-sdk/inbound.ts +156 -8
  150. package/src/integrations/chat-sdk/limits.ts +24 -0
  151. package/src/integrations/chat-sdk/outbound-files.ts +186 -0
  152. package/src/integrations/chat-sdk/outbound.ts +234 -4
  153. package/src/integrations/chat-sdk/relay.ts +4 -1
  154. package/src/integrations/chat-sdk/router.ts +28 -2
  155. package/src/integrations/chat-sdk/steering.ts +9 -0
  156. package/src/integrations/chat-sdk/templates.ts +14 -0
  157. package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
  158. package/src/mcp-limits.ts +20 -0
  159. package/src/pages/browser-sdk.ts +8 -3
  160. package/src/pages/feedback-overlay.ts +1 -1
  161. package/src/providers/acp-adapter.ts +33 -13
  162. package/src/providers/acp-swarm-events.ts +1 -1
  163. package/src/providers/amp-adapter.ts +1 -1
  164. package/src/providers/codex-adapter.ts +106 -119
  165. package/src/providers/codex-agents-md.ts +35 -229
  166. package/src/providers/codex-oauth/storage.ts +1 -1
  167. package/src/providers/cursor-adapter.ts +1 -1
  168. package/src/rbac/legacy-policy.ts +3 -1
  169. package/src/rbac/permissions.ts +4 -0
  170. package/src/realtime/transport.ts +1 -2
  171. package/src/script-workflows/workflow-ctx.ts +1 -0
  172. package/src/scripts-runtime/types/stdlib.d.ts +2 -0
  173. package/src/scripts-runtime/types/swarm-extension.d.ts +8 -0
  174. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  175. package/src/server-user.ts +2 -0
  176. package/src/server.ts +2 -0
  177. package/src/slack/assistant.ts +2 -2
  178. package/src/slack/files.ts +2 -0
  179. package/src/slack/handlers.ts +4 -4
  180. package/src/slack/inbound-files.ts +39 -6
  181. package/src/slack/thread-buffer.ts +2 -1
  182. package/src/tasks/task-terminal-effects.ts +21 -17
  183. package/src/tests/acp-adapter.test.ts +10 -23
  184. package/src/tests/acp-fake-agent.ts +36 -0
  185. package/src/tests/acp-swarm-events.test.ts +1 -1
  186. package/src/tests/agent-session-tokens.test.ts +452 -0
  187. package/src/tests/apps-action-parent-task.test.ts +295 -0
  188. package/src/tests/apps-card-grid.test.ts +197 -0
  189. package/src/tests/apps-catalog-additions.test.ts +120 -0
  190. package/src/tests/apps-tree.test.ts +129 -0
  191. package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
  192. package/src/tests/chat-sdk-config.test.ts +11 -0
  193. package/src/tests/chat-sdk-facts.test.ts +23 -0
  194. package/src/tests/chat-sdk-inbound.test.ts +558 -4
  195. package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
  196. package/src/tests/codex-adapter.test.ts +167 -242
  197. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  198. package/src/tests/config-api-only-keys.test.ts +9 -0
  199. package/src/tests/config-session-auth.test.ts +14 -0
  200. package/src/tests/cors-allowlist.test.ts +1 -1
  201. package/src/tests/docs-index-footprint.test.js +2 -2
  202. package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
  203. package/src/tests/grok-adapter.test.ts +3 -3
  204. package/src/tests/heartbeat-checklist.test.ts +33 -0
  205. package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
  206. package/src/tests/heartbeat-jev-gate.test.ts +265 -1
  207. package/src/tests/kv-page-proxy.test.ts +91 -5
  208. package/src/tests/launch-password-rejection.test.ts +2 -2
  209. package/src/tests/mcp-input-caps.test.ts +294 -0
  210. package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
  211. package/src/tests/memory-embedding-input-cap.test.ts +114 -0
  212. package/src/tests/memory.test.ts +14 -0
  213. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  214. package/src/tests/page-guest-session.test.ts +19 -8
  215. package/src/tests/page-proxy-authed.test.ts +1 -1
  216. package/src/tests/page-proxy.test.ts +17 -10
  217. package/src/tests/page-session.test.ts +102 -1
  218. package/src/tests/pages-authed-mode.test.ts +82 -12
  219. package/src/tests/pages-password-mode.test.ts +5 -5
  220. package/src/tests/rbac-engine.test.ts +1 -0
  221. package/src/tests/realtime-transport.test.ts +13 -7
  222. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  223. package/src/tests/script-connections.test.ts +2 -0
  224. package/src/tests/script-runs-http.test.ts +84 -0
  225. package/src/tests/scripts-http.test.ts +10 -1
  226. package/src/tests/session-route-posture.test.ts +2 -0
  227. package/src/tests/swarm-config-export-to-env.test.ts +22 -3
  228. package/src/tests/task-audio-transcription.test.ts +299 -0
  229. package/src/tests/task-completion-memory-chunking.test.ts +434 -0
  230. package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
  231. package/src/tests/transcription.test.ts +299 -0
  232. package/src/tests/worker-token-cli.test.ts +76 -0
  233. package/src/tests/workflow-executors.test.ts +4 -2
  234. package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
  235. package/src/tests/workflow-system-one-decision.test.ts +2 -1
  236. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  237. package/src/tools/chat-access.ts +10 -0
  238. package/src/tools/chat-post.ts +31 -11
  239. package/src/tools/chat-reply.ts +32 -10
  240. package/src/tools/memory-search.ts +3 -1
  241. package/src/tools/system-one-decide.ts +17 -4
  242. package/src/types.ts +8 -1
  243. package/src/utils/config-env-export.ts +3 -1
  244. package/src/utils/page-session.ts +133 -20
  245. package/src/utils/request-auth-context.ts +2 -1
  246. package/src/utils/transcription.ts +246 -0
  247. package/src/workflows/definition.ts +7 -1
  248. package/src/workflows/engine.ts +18 -12
  249. package/src/workflows/executors/index.ts +1 -0
  250. package/src/workflows/executors/registry.ts +2 -0
  251. package/src/workflows/executors/system-one-decision.ts +84 -262
  252. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  253. package/src/workflows/executors/system-one-extraction.ts +830 -0
  254. package/src/workflows/executors/system-one-providers.ts +316 -114
  255. package/src/workflows/executors/system-one-transport.ts +264 -0
  256. package/templates/extensions/slack-reply-gate/README.md +24 -22
  257. package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
  258. package/templates/extensions/slack-reply-gate/manifest.json +2 -2
  259. package/templates/official/lead/HEARTBEAT.md +1 -1
  260. package/templates/skills/apps/SKILL.md +31 -7
  261. package/templates/skills/apps/content.md +31 -7
  262. package/templates/skills/chat-sdk/content.md +6 -2
  263. package/templates/skills/code-quality/content.md +2 -1
  264. package/templates/skills/heartbeat-runbook/content.md +8 -8
  265. package/templates/skills/pages/content.md +6 -4
  266. package/templates/skills/swarm-scripts/SKILL.md +2 -2
  267. package/templates/skills/swarm-scripts/content.md +2 -2
  268. package/dist/cli-v1sjhn52.js +0 -7
  269. package/dist/handlers-2qjw434w.js +0 -39
  270. package/dist/index-d0g18bax.js +0 -41
@@ -1,17 +1,21 @@
1
1
  import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../../utils/openrouter-base-url";
2
2
  import type { ExecutorDependencies } from "./base";
3
+ import { CLOUDFLARE_EXTRACT_WIRE, type SystemOneExtractWire } from "./system-one-extract-wire";
3
4
  import { OPENAI_DECISIONS_WIRE } from "./system-one-openai-wire";
4
5
  import { CLOUDFLARE_WIRE, type SystemOneWire } from "./system-one-wire";
5
6
  import { resolveWorkflowLlmConfig } from "./workflow-llm";
6
7
 
7
8
  /**
8
- * Hosts that serve typed decisions (Jev, laya, OpenAI Decisions, Cloudflare Clef). A
9
- * `system-one-decision` node names one by id (`config.provider`); the endpoint, header
10
- * shape, and key name live here, never in a workflow definition. Most hosts take the
11
- * SystemOne `{ state, model, questions }` request and return its `{ model, answers, usage }`
12
- * body; a host that does not supplies a `wire` that translates both ways, so the
13
- * answer contract in `system-one-decision.ts` is shared. Adding a host is one entry
14
- * in `SYSTEM_ONE_PROVIDERS`.
9
+ * Hosts of System One models. One registry serves every capability: a host
10
+ * declares what it serves (`decide` for typed decisions: Jev, laya, OpenAI
11
+ * Decisions, Cloudflare Clef; `extract` for typed extraction: Cloudflare Clairic)
12
+ * and, per capability, its endpoint, models, limits, and wire. The credential, the
13
+ * deployment check, and every message are the host's, so a decision node and an
14
+ * extraction node on the same host fail with the same sentence.
15
+ *
16
+ * A node names a host by id (`config.provider`); the endpoint, header shape, and
17
+ * key name live here, never in a workflow definition. Adding a host, or a
18
+ * capability to one, is one entry in `SYSTEM_ONE_HOSTS`.
15
19
  *
16
20
  * Do not add a chat-completions route. OpenRouter's `typesafe/jev-router` is a
17
21
  * router that forwards a chat request to another model, and `~typesafe/jev-latest`
@@ -19,6 +23,8 @@ import { resolveWorkflowLlmConfig } from "./workflow-llm";
19
23
  * runs Jev itself.
20
24
  */
21
25
 
26
+ export type SystemOneCapability = "decide" | "extract";
27
+
22
28
  export interface SystemOneProviderContext {
23
29
  db: ExecutorDependencies["db"];
24
30
  env: NodeJS.ProcessEnv;
@@ -48,13 +54,18 @@ export interface SystemOneBuiltEndpoint {
48
54
  readonly build: (value: string, model: string) => string | null;
49
55
  }
50
56
 
51
- export interface SystemOneProvider {
52
- /** Name used in messages, e.g. "TypeSafe". */
53
- readonly label: string;
54
- /** The single credential this host needs: a global secret name. */
55
- readonly keyName: string;
57
+ export type SystemOneEndpoint = string | SystemOneConfiguredEndpoint | SystemOneBuiltEndpoint;
58
+
59
+ /** A credential read from config, with the name it was read under. */
60
+ export interface SystemOneCredential {
61
+ value: string;
62
+ keyName: string;
63
+ }
64
+
65
+ /** What every capability of a host declares. */
66
+ interface SystemOneServiceBase {
56
67
  /** A fixed URL, the global config key that names the server, or a URL built from config and model. */
57
- readonly endpoint: string | SystemOneConfiguredEndpoint | SystemOneBuiltEndpoint;
68
+ readonly endpoint: SystemOneEndpoint;
58
69
  /**
59
70
  * Model used when the node sets none. Absent means no `model` is sent and the
60
71
  * host picks, for a host that ignores a model id it does not know.
@@ -62,6 +73,9 @@ export interface SystemOneProvider {
62
73
  readonly defaultModel?: string;
63
74
  /** When set, the only model ids the node may use (checked at save and at run). */
64
75
  readonly allowedModels?: readonly string[];
76
+ }
77
+
78
+ export interface SystemOneDecideService extends SystemOneServiceBase {
65
79
  /** Host limits stricter than the node's own. */
66
80
  readonly limits?: { readonly maxQuestions?: number };
67
81
  /** Request and response translation. Absent means the SystemOne wire as is. */
@@ -71,18 +85,48 @@ export interface SystemOneProvider {
71
85
  * the output reports and what a `humanReview` band is tested against.
72
86
  */
73
87
  readonly confidenceField: "confidence" | "answer_confidence";
88
+ }
89
+
90
+ export interface SystemOneExtractService extends SystemOneServiceBase {
91
+ /** Host limits stricter than the node's own. */
92
+ readonly limits?: { readonly maxFields?: number; readonly maxImages?: number };
93
+ /** Request and response translation. */
94
+ readonly wire: SystemOneExtractWire;
95
+ }
96
+
97
+ export interface SystemOneHost {
98
+ /** Name used in messages, e.g. "TypeSafe". */
99
+ readonly label: string;
100
+ /** The credential this host needs: a global secret name, the one messages tell an author to set. */
101
+ readonly keyName: string;
102
+ /** Other secret names read, in order, when `keyName` is unset. Messages mention them. */
103
+ readonly fallbackKeyNames?: readonly string[];
74
104
  /** A deployment setting that makes this host unusable, or null. */
75
105
  readonly deploymentProblem?: (env: NodeJS.ProcessEnv) => string | null;
76
- /** The credential value, or null/blank when absent. May throw; callers never relay the cause. */
77
- readonly readKey: (ctx: SystemOneProviderContext) => Promise<string | null | undefined>;
106
+ /**
107
+ * The credential value (a string is read under `keyName`), or null/blank when
108
+ * absent. May throw; callers never relay the cause.
109
+ */
110
+ readonly readKey: (
111
+ ctx: SystemOneProviderContext,
112
+ ) => Promise<SystemOneCredential | string | null | undefined>;
113
+ readonly decide?: SystemOneDecideService;
114
+ readonly extract?: SystemOneExtractService;
78
115
  }
79
116
 
117
+ /**
118
+ * A decision host as one flat record: the host's credential and messages plus its
119
+ * `decide` service. `system-one-decision` reads this shape.
120
+ */
121
+ export type SystemOneProvider = Omit<SystemOneHost, "decide" | "extract"> & SystemOneDecideService;
122
+
80
123
  const TYPESAFE_KEY = "TYPESAFE_API_KEY";
81
124
  const OPENROUTER_KEY = "OPENROUTER_API_KEY";
82
125
  const LAYA_KEY = "LAYA_API_KEY";
83
126
  const LAYA_URL_KEY = "LAYA_URL";
84
127
  const OPENAI_DECISIONS_KEY = "OPENAI_DECISIONS_API_KEY";
85
128
  const CLOUDFLARE_TOKEN_KEY = "CLOUDFLARE_API_TOKEN";
129
+ const CLOUDFLARE_KEY_FALLBACK = "CLOUDFLARE_API_KEY";
86
130
  const CLOUDFLARE_ACCOUNT_KEY = "CLOUDFLARE_ACCOUNT_ID";
87
131
  const CLOUDFLARE_ACCOUNT_RE = /^[0-9a-f]{32}$/;
88
132
 
@@ -95,21 +139,44 @@ async function readGlobalConfig(
95
139
  return rows.find((row) => row.scope === "global" && row.key === key)?.value;
96
140
  }
97
141
 
98
- export const SYSTEM_ONE_PROVIDERS = {
142
+ /** The first of `keys` with a non-blank global value, with the key it came from. */
143
+ async function readFirstGlobalConfig(
144
+ db: ExecutorDependencies["db"],
145
+ keys: readonly string[],
146
+ ): Promise<SystemOneCredential | undefined> {
147
+ for (const keyName of keys) {
148
+ const value = await readGlobalConfig(db, keyName);
149
+ if (value && value.trim() !== "") return { value, keyName };
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ /** Every Workers AI model is reached at the same account-scoped route; only the model differs. */
155
+ const CLOUDFLARE_ENDPOINT: SystemOneBuiltEndpoint = {
156
+ configKey: CLOUDFLARE_ACCOUNT_KEY,
157
+ describe: "the 32-character id of a Cloudflare account",
158
+ build: (accountId, model) => {
159
+ const id = accountId.trim();
160
+ return CLOUDFLARE_ACCOUNT_RE.test(id)
161
+ ? `https://api.cloudflare.com/client/v4/accounts/${id}/ai/run/@cf/cloudflare/${model}`
162
+ : null;
163
+ },
164
+ };
165
+
166
+ export const SYSTEM_ONE_HOSTS = {
99
167
  typesafe: {
100
168
  label: "TypeSafe",
101
169
  keyName: TYPESAFE_KEY,
102
- endpoint: "https://api.typesafe.ai/v1/systemone",
103
- defaultModel: "jev-latest",
104
- confidenceField: "confidence",
105
170
  readKey: ({ db }) => readGlobalConfig(db, TYPESAFE_KEY),
171
+ decide: {
172
+ endpoint: "https://api.typesafe.ai/v1/systemone",
173
+ defaultModel: "jev-latest",
174
+ confidenceField: "confidence",
175
+ },
106
176
  },
107
177
  openrouter: {
108
178
  label: "OpenRouter",
109
179
  keyName: OPENROUTER_KEY,
110
- endpoint: "https://openrouter.ai/api/alpha/decisions",
111
- defaultModel: "~typesafe/jev-latest",
112
- confidenceField: "confidence",
113
180
  deploymentProblem(env) {
114
181
  // A gateway URL means OPENROUTER_API_KEY may be a gateway token, and the
115
182
  // decisions API is a sibling of /v1 that a chat gateway need not forward.
@@ -127,158 +194,276 @@ export const SYSTEM_ONE_PROVIDERS = {
127
194
  return null;
128
195
  }
129
196
  },
197
+ decide: {
198
+ endpoint: "https://openrouter.ai/api/alpha/decisions",
199
+ defaultModel: "~typesafe/jev-latest",
200
+ confidenceField: "confidence",
201
+ },
130
202
  },
131
203
  laya: {
132
204
  label: "laya",
133
205
  keyName: LAYA_KEY,
134
- endpoint: { configKey: LAYA_URL_KEY, path: "/v1/systemone" },
135
- // No default model. laya answers an id it does not know (`jev-latest` included) by
136
- // routing on the text instead, with HTTP 200, so a default would be sent as if it meant something.
137
- // `confidence` on a choice or score is entropy-based (0.47 top probability reads
138
- // ~0.15). `answer_confidence` is the top probability, the number a `noul` band
139
- // already uses, so one band means the same thing on every question type.
140
- confidenceField: "answer_confidence",
141
206
  readKey: ({ db }) => readGlobalConfig(db, LAYA_KEY),
207
+ decide: {
208
+ endpoint: { configKey: LAYA_URL_KEY, path: "/v1/systemone" },
209
+ // No default model. laya answers an id it does not know (`jev-latest` included) by
210
+ // routing on the text instead, with HTTP 200, so a default would be sent as if it meant something.
211
+ // `confidence` on a choice or score is entropy-based (0.47 top probability reads
212
+ // ~0.15). `answer_confidence` is the top probability, the number a `noul` band
213
+ // already uses, so one band means the same thing on every question type.
214
+ confidenceField: "answer_confidence",
215
+ },
142
216
  },
143
217
  openai: {
144
218
  label: "OpenAI",
145
219
  // Not OPENAI_API_KEY: a global row of that name reaches every worker's env and
146
220
  // can change harness auth. This key serves this node only, and needs Decisions access.
147
221
  keyName: OPENAI_DECISIONS_KEY,
148
- endpoint: "https://api.openai.com/v1/decisions",
149
- defaultModel: "gpt-6-luna",
150
- // OpenAI's own field; it is not the top probability.
151
- confidenceField: "confidence",
152
- wire: OPENAI_DECISIONS_WIRE,
153
222
  readKey: ({ db }) => readGlobalConfig(db, OPENAI_DECISIONS_KEY),
223
+ decide: {
224
+ endpoint: "https://api.openai.com/v1/decisions",
225
+ defaultModel: "gpt-6-luna",
226
+ // OpenAI's own field; it is not the top probability.
227
+ confidenceField: "confidence",
228
+ wire: OPENAI_DECISIONS_WIRE,
229
+ },
154
230
  },
155
231
  cloudflare: {
156
232
  label: "Cloudflare Workers AI",
157
233
  keyName: CLOUDFLARE_TOKEN_KEY,
158
- endpoint: {
159
- configKey: CLOUDFLARE_ACCOUNT_KEY,
160
- describe: "the 32-character id of a Cloudflare account",
161
- build: (accountId, model) => {
162
- const id = accountId.trim();
163
- return CLOUDFLARE_ACCOUNT_RE.test(id)
164
- ? `https://api.cloudflare.com/client/v4/accounts/${id}/ai/run/@cf/cloudflare/${model}`
165
- : null;
166
- },
234
+ // A Workers AI API token is a bearer token whatever the secret is called; a
235
+ // deployment that stored it as CLOUDFLARE_API_KEY works without a rename.
236
+ fallbackKeyNames: [CLOUDFLARE_KEY_FALLBACK],
237
+ readKey: ({ db }) => readFirstGlobalConfig(db, [CLOUDFLARE_TOKEN_KEY, CLOUDFLARE_KEY_FALLBACK]),
238
+ decide: {
239
+ endpoint: CLOUDFLARE_ENDPOINT,
240
+ defaultModel: "clef",
241
+ // The model is part of the URL, so only these ids are accepted.
242
+ allowedModels: ["clef", "clef-flash"],
243
+ limits: { maxQuestions: 64 },
244
+ confidenceField: "confidence",
245
+ wire: CLOUDFLARE_WIRE,
246
+ },
247
+ extract: {
248
+ endpoint: CLOUDFLARE_ENDPOINT,
249
+ defaultModel: "clairic",
250
+ allowedModels: ["clairic"],
251
+ limits: { maxFields: 64, maxImages: 4 },
252
+ wire: CLOUDFLARE_EXTRACT_WIRE,
167
253
  },
168
- defaultModel: "clef",
169
- // The model is part of the URL, so only these ids are accepted.
170
- allowedModels: ["clef", "clef-flash"],
171
- limits: { maxQuestions: 64 },
172
- confidenceField: "confidence",
173
- wire: CLOUDFLARE_WIRE,
174
- readKey: ({ db }) => readGlobalConfig(db, CLOUDFLARE_TOKEN_KEY),
175
254
  },
176
- } as const satisfies Record<string, SystemOneProvider>;
255
+ } as const satisfies Record<string, SystemOneHost>;
177
256
 
178
- export type SystemOneProviderId = keyof typeof SYSTEM_ONE_PROVIDERS;
257
+ export type SystemOneHostId = keyof typeof SYSTEM_ONE_HOSTS;
179
258
 
180
- export const SYSTEM_ONE_PROVIDER_IDS = Object.keys(SYSTEM_ONE_PROVIDERS) as [
181
- SystemOneProviderId,
182
- ...SystemOneProviderId[],
183
- ];
259
+ type HostsServing<C extends SystemOneCapability> = {
260
+ [K in SystemOneHostId]: (typeof SYSTEM_ONE_HOSTS)[K] extends Record<C, object> ? K : never;
261
+ }[SystemOneHostId];
184
262
 
263
+ /** A host that serves typed decisions. */
264
+ export type SystemOneProviderId = HostsServing<"decide">;
265
+ /** A host that serves typed extraction. */
266
+ export type SystemOneExtractProviderId = HostsServing<"extract">;
267
+
268
+ function hostsServing<C extends SystemOneCapability>(
269
+ capability: C,
270
+ ): [HostsServing<C>, ...HostsServing<C>[]] {
271
+ const ids = (Object.keys(SYSTEM_ONE_HOSTS) as SystemOneHostId[]).filter(
272
+ (id) => (SYSTEM_ONE_HOSTS[id] as SystemOneHost)[capability] !== undefined,
273
+ );
274
+ return ids as [HostsServing<C>, ...HostsServing<C>[]];
275
+ }
276
+
277
+ /** The decision view of every host that decides: the host's shared fields plus its `decide` service. */
278
+ export const SYSTEM_ONE_PROVIDERS = Object.fromEntries(
279
+ hostsServing("decide").map((id) => {
280
+ const { decide, extract: _extract, ...host } = SYSTEM_ONE_HOSTS[id] as SystemOneHost;
281
+ return [id, { ...host, ...decide }];
282
+ }),
283
+ ) as {
284
+ [K in SystemOneProviderId]: Omit<(typeof SYSTEM_ONE_HOSTS)[K], "decide" | "extract"> &
285
+ (typeof SYSTEM_ONE_HOSTS)[K]["decide"];
286
+ };
287
+
288
+ export const SYSTEM_ONE_PROVIDER_IDS = hostsServing("decide");
289
+ export const SYSTEM_ONE_EXTRACT_PROVIDER_IDS = hostsServing("extract");
290
+
291
+ /** The decision provider when neither the call nor `SYSTEM_ONE_DEFAULT_PROVIDER` names one. */
185
292
  export const SYSTEM_ONE_DEFAULT_PROVIDER: SystemOneProviderId = "typesafe";
293
+ export const SYSTEM_ONE_EXTRACT_DEFAULT_PROVIDER: SystemOneExtractProviderId = "cloudflare";
294
+
295
+ /** Global config key naming the decision provider a node or call without `provider` uses. */
296
+ export const SYSTEM_ONE_DEFAULT_PROVIDER_CONFIG_KEY = "SYSTEM_ONE_DEFAULT_PROVIDER";
186
297
 
187
298
  export function isSystemOneProviderId(value: unknown): value is SystemOneProviderId {
188
- return typeof value === "string" && Object.hasOwn(SYSTEM_ONE_PROVIDERS, value);
299
+ return (
300
+ typeof value === "string" && (SYSTEM_ONE_PROVIDER_IDS as readonly string[]).includes(value)
301
+ );
302
+ }
303
+
304
+ /**
305
+ * The decision provider for a node or call that names none: the global
306
+ * `SYSTEM_ONE_DEFAULT_PROVIDER` row, then the same key in the environment, then
307
+ * `typesafe`. Read at run time, so changing the setting needs no workflow edit.
308
+ * A value that is not a decide host is skipped; the config guard refuses it on save.
309
+ */
310
+ export async function resolveSystemOneDefaultProvider(
311
+ ctx: SystemOneProviderContext,
312
+ ): Promise<SystemOneProviderId> {
313
+ let stored: string | undefined;
314
+ try {
315
+ stored = await readGlobalConfig(ctx.db, SYSTEM_ONE_DEFAULT_PROVIDER_CONFIG_KEY);
316
+ } catch {
317
+ // An unreadable row falls back like an unset one: the default stays usable.
318
+ }
319
+ for (const raw of [stored, ctx.env[SYSTEM_ONE_DEFAULT_PROVIDER_CONFIG_KEY]]) {
320
+ const id = raw?.trim().toLowerCase();
321
+ if (isSystemOneProviderId(id)) return id;
322
+ }
323
+ return SYSTEM_ONE_DEFAULT_PROVIDER;
324
+ }
325
+
326
+ export function isSystemOneExtractProviderId(value: unknown): value is SystemOneExtractProviderId {
327
+ return (
328
+ typeof value === "string" &&
329
+ (SYSTEM_ONE_EXTRACT_PROVIDER_IDS as readonly string[]).includes(value)
330
+ );
189
331
  }
190
332
 
191
- /** Provider a raw node config selects. Undefined when the field is present but not a known id. */
333
+ /**
334
+ * Provider a raw node config selects, `defaultId` when it names none. Undefined
335
+ * when the field is present but not a known id.
336
+ */
192
337
  export function systemOneProviderOf(
193
338
  config: Record<string, unknown>,
339
+ defaultId: SystemOneProviderId = SYSTEM_ONE_DEFAULT_PROVIDER,
194
340
  ): SystemOneProviderId | undefined {
195
- if (config.provider === undefined) return SYSTEM_ONE_DEFAULT_PROVIDER;
341
+ if (config.provider === undefined) return defaultId;
196
342
  return isSystemOneProviderId(config.provider) ? config.provider : undefined;
197
343
  }
198
344
 
345
+ /** Extraction provider a raw node config selects. Undefined when present but not a known id. */
346
+ export function systemOneExtractProviderOf(
347
+ config: Record<string, unknown>,
348
+ ): SystemOneExtractProviderId | undefined {
349
+ if (config.provider === undefined) return SYSTEM_ONE_EXTRACT_DEFAULT_PROVIDER;
350
+ return isSystemOneExtractProviderId(config.provider) ? config.provider : undefined;
351
+ }
352
+
353
+ /** The service a host runs for one capability. Callers pass an id that serves it. */
354
+ function serviceOf(id: SystemOneHostId, capability: SystemOneCapability): SystemOneServiceBase {
355
+ const service = (SYSTEM_ONE_HOSTS[id] as SystemOneHost)[capability];
356
+ if (!service) throw new Error(`SystemOne provider "${id}" does not serve ${capability}`);
357
+ return service;
358
+ }
359
+
199
360
  // ─── Messages ───────────────────────────────────────────────
200
361
  //
201
362
  // Every message names the exact credential and where to set it. They are shared by
202
363
  // the save-time warning, the run-start stop, and the step itself, so an author
203
364
  // sees the same sentence wherever the problem surfaces.
204
365
 
366
+ /** What a call that failed did not produce, for the end of a message. */
367
+ const NOTHING_PRODUCED: Record<SystemOneCapability, string> = {
368
+ decide: "No decision was made.",
369
+ extract: "Nothing was extracted.",
370
+ };
371
+
372
+ export function systemOneNothingProduced(capability: SystemOneCapability): string {
373
+ return NOTHING_PRODUCED[capability];
374
+ }
375
+
205
376
  const SET_IT = (keyName: string) =>
206
377
  `Set ${keyName} as a global secret on the Secrets page (Settings > Secrets).`;
207
378
 
208
379
  const SET_URL = (configKey: string) =>
209
380
  `Set ${configKey} as a global config value (set-config with scope global).`;
210
381
 
211
- export function systemOneKeyMissingMessage(id: SystemOneProviderId): string {
212
- const { label, keyName } = SYSTEM_ONE_PROVIDERS[id];
213
- return `${keyName} is not configured. SystemOne provider "${id}" needs a working ${label} API key. ${SET_IT(keyName)}`;
382
+ export function systemOneKeyMissingMessage(id: SystemOneHostId): string {
383
+ const { label, keyName, fallbackKeyNames }: SystemOneHost = SYSTEM_ONE_HOSTS[id];
384
+ const also = fallbackKeyNames?.length
385
+ ? ` (${fallbackKeyNames.join(", ")} is read when it is unset, and is not configured either)`
386
+ : "";
387
+ return `${keyName} is not configured${also}. SystemOne provider "${id}" needs a working ${label} API key. ${SET_IT(keyName)}`;
214
388
  }
215
389
 
216
- export function systemOneKeyUnreadableMessage(id: SystemOneProviderId): string {
390
+ export function systemOneKeyUnreadableMessage(id: SystemOneHostId): string {
217
391
  // The underlying error can name a config row id; keep the message generic.
218
- return `Could not read ${SYSTEM_ONE_PROVIDERS[id].keyName} from swarm config`;
392
+ return `Could not read ${SYSTEM_ONE_HOSTS[id].keyName} from swarm config`;
219
393
  }
220
394
 
221
- export function systemOneKeyMalformedMessage(id: SystemOneProviderId): string {
222
- return `${SYSTEM_ONE_PROVIDERS[id].keyName} is not a valid bearer token value. ${SET_IT(SYSTEM_ONE_PROVIDERS[id].keyName)}`;
395
+ export function systemOneKeyMalformedMessage(id: SystemOneHostId, keyName?: string): string {
396
+ const name = keyName ?? SYSTEM_ONE_HOSTS[id].keyName;
397
+ return `${name} is not a valid bearer token value. ${SET_IT(name)}`;
223
398
  }
224
399
 
225
400
  /** A 401 or 403 from the host: the key exists but the host does not accept it. */
226
401
  export function systemOneKeyRejectedMessage(
227
- id: SystemOneProviderId,
402
+ id: SystemOneHostId,
228
403
  status: number,
229
404
  code?: string,
405
+ options: { keyName?: string; capability?: SystemOneCapability } = {},
230
406
  ): string {
231
- const { label, keyName } = SYSTEM_ONE_PROVIDERS[id];
407
+ const { label }: SystemOneHost = SYSTEM_ONE_HOSTS[id];
408
+ const keyName = options.keyName ?? SYSTEM_ONE_HOSTS[id].keyName;
232
409
  const detail = code ? ` (${code})` : "";
233
410
  return (
234
411
  `${keyName} was rejected by ${label} (HTTP ${status}${detail}). ` +
235
- `The key is invalid, revoked, or lacks access to the model. Replace it on the Secrets page (Settings > Secrets). No decision was made.`
412
+ `The key is invalid, revoked, or lacks access to the model. Replace it on the Secrets page (Settings > Secrets). ${NOTHING_PRODUCED[options.capability ?? "decide"]}`
236
413
  );
237
414
  }
238
415
 
239
- function urlKeyOf(provider: SystemOneProvider): string | null {
240
- return typeof provider.endpoint === "string" ? null : provider.endpoint.configKey;
416
+ function urlKeyOf(endpoint: SystemOneEndpoint): string | null {
417
+ return typeof endpoint === "string" ? null : endpoint.configKey;
241
418
  }
242
419
 
243
- function isBuiltEndpoint(
244
- endpoint: SystemOneProvider["endpoint"],
245
- ): endpoint is SystemOneBuiltEndpoint {
420
+ function isBuiltEndpoint(endpoint: SystemOneEndpoint): endpoint is SystemOneBuiltEndpoint {
246
421
  return typeof endpoint !== "string" && "build" in endpoint;
247
422
  }
248
423
 
249
424
  /** What a provider's config value names, for messages. */
250
- function urlDescriptionOf(provider: SystemOneProvider): string {
251
- return isBuiltEndpoint(provider.endpoint)
252
- ? provider.endpoint.describe
253
- : `the URL of a ${provider.label} server`;
425
+ function urlDescriptionOf(id: SystemOneHostId, endpoint: SystemOneEndpoint): string {
426
+ return isBuiltEndpoint(endpoint)
427
+ ? endpoint.describe
428
+ : `the URL of a ${SYSTEM_ONE_HOSTS[id].label} server`;
254
429
  }
255
430
 
256
- export function systemOneUrlMissingMessage(id: SystemOneProviderId): string {
257
- const provider: SystemOneProvider = SYSTEM_ONE_PROVIDERS[id];
258
- const configKey = urlKeyOf(provider) ?? "";
259
- return `${configKey} is not configured. SystemOne provider "${id}" needs ${urlDescriptionOf(provider)}. ${SET_URL(configKey)}`;
431
+ export function systemOneUrlMissingMessage(
432
+ id: SystemOneHostId,
433
+ capability: SystemOneCapability = "decide",
434
+ ): string {
435
+ const { endpoint } = serviceOf(id, capability);
436
+ const configKey = urlKeyOf(endpoint) ?? "";
437
+ return `${configKey} is not configured. SystemOne provider "${id}" needs ${urlDescriptionOf(id, endpoint)}. ${SET_URL(configKey)}`;
260
438
  }
261
439
 
262
- export function systemOneUrlUnreadableMessage(id: SystemOneProviderId): string {
263
- return `Could not read ${urlKeyOf(SYSTEM_ONE_PROVIDERS[id]) ?? ""} from swarm config`;
440
+ export function systemOneUrlUnreadableMessage(
441
+ id: SystemOneHostId,
442
+ capability: SystemOneCapability = "decide",
443
+ ): string {
444
+ return `Could not read ${urlKeyOf(serviceOf(id, capability).endpoint) ?? ""} from swarm config`;
264
445
  }
265
446
 
266
- export function systemOneUrlInvalidMessage(id: SystemOneProviderId): string {
267
- const provider: SystemOneProvider = SYSTEM_ONE_PROVIDERS[id];
268
- const configKey = urlKeyOf(provider) ?? "";
447
+ export function systemOneUrlInvalidMessage(
448
+ id: SystemOneHostId,
449
+ capability: SystemOneCapability = "decide",
450
+ ): string {
451
+ const { endpoint } = serviceOf(id, capability);
452
+ const configKey = urlKeyOf(endpoint) ?? "";
269
453
  // The value is not echoed: a URL can carry a credential.
270
- if (isBuiltEndpoint(provider.endpoint)) {
271
- return `${configKey} is not ${provider.endpoint.describe}. ${SET_URL(configKey)}`;
454
+ if (isBuiltEndpoint(endpoint)) {
455
+ return `${configKey} is not ${endpoint.describe}. ${SET_URL(configKey)}`;
272
456
  }
273
- return `${configKey} is not a usable ${provider.label} server URL. It must be an https URL (http only for localhost) with no credentials, query, or fragment. ${SET_URL(configKey)}`;
457
+ return `${configKey} is not a usable ${SYSTEM_ONE_HOSTS[id].label} server URL. It must be an https URL (http only for localhost) with no credentials, query, or fragment. ${SET_URL(configKey)}`;
274
458
  }
275
459
 
276
460
  /** Why a provider cannot use this model, or null. The value is not echoed: it may come from run input. */
277
461
  export function systemOneModelProblem(
278
- id: SystemOneProviderId,
462
+ id: SystemOneHostId,
279
463
  model: string | undefined,
464
+ capability: SystemOneCapability = "decide",
280
465
  ): string | null {
281
- const { allowedModels }: SystemOneProvider = SYSTEM_ONE_PROVIDERS[id];
466
+ const { allowedModels } = serviceOf(id, capability);
282
467
  if (!allowedModels || (model !== undefined && allowedModels.includes(model))) return null;
283
468
  return `SystemOne provider "${id}" serves only the models ${allowedModels.join(", ")}; set model to one of them or leave it unset.`;
284
469
  }
@@ -327,16 +512,29 @@ export function systemOneEndpointFromBase(base: string, path: string): string |
327
512
  }
328
513
 
329
514
  export type SystemOneTarget =
330
- | { ok: true; apiKey: string; endpoint: string }
331
- | { ok: false; error: string };
515
+ | {
516
+ ok: true;
517
+ apiKey: string;
518
+ endpoint: string;
519
+ /** The secret name the key was read under, for a rejection message. */
520
+ keyName: string;
521
+ }
522
+ | {
523
+ ok: false;
524
+ error: string;
525
+ /** This deployment refuses the host whatever its credential (`deploymentProblem`). */
526
+ deploymentRefused?: true;
527
+ };
332
528
 
333
529
  export interface SystemOneTargetOverrides {
334
530
  /** Replaces the credential lookup. */
335
- apiKey?: (id: SystemOneProviderId) => Promise<string | null | undefined>;
531
+ apiKey?: (id: SystemOneHostId) => Promise<string | null | undefined>;
336
532
  /** Replaces the lookup of a configured server's base URL (or account id). */
337
- serverUrl?: (id: SystemOneProviderId) => Promise<string | null | undefined>;
338
- /** The model the call uses, for a host whose URL names it. Defaults to the provider's default. */
533
+ serverUrl?: (id: SystemOneHostId) => Promise<string | null | undefined>;
534
+ /** The model the call uses, for a host whose URL names it. Defaults to the service's default. */
339
535
  model?: string;
536
+ /** Which of the host's services is reached. Defaults to `decide`. */
537
+ capability?: SystemOneCapability;
340
538
  }
341
539
 
342
540
  /**
@@ -346,21 +544,23 @@ export interface SystemOneTargetOverrides {
346
544
  * fakes); the deployment check and the blank / malformed / URL checks still apply.
347
545
  */
348
546
  export async function resolveSystemOneTarget(
349
- id: SystemOneProviderId,
547
+ id: SystemOneHostId,
350
548
  ctx: SystemOneProviderContext,
351
549
  overrides: SystemOneTargetOverrides = {},
352
550
  ): Promise<SystemOneTarget> {
353
- const provider: SystemOneProvider = SYSTEM_ONE_PROVIDERS[id];
354
- const deploymentProblem = provider.deploymentProblem?.(ctx.env);
355
- if (deploymentProblem) return { ok: false, error: deploymentProblem };
551
+ const host: SystemOneHost = SYSTEM_ONE_HOSTS[id];
552
+ const capability = overrides.capability ?? "decide";
553
+ const service = serviceOf(id, capability);
554
+ const deploymentProblem = host.deploymentProblem?.(ctx.env);
555
+ if (deploymentProblem) return { ok: false, error: deploymentProblem, deploymentRefused: true };
356
556
 
357
557
  const errors: string[] = [];
358
558
 
359
559
  let endpoint: string | undefined;
360
- if (typeof provider.endpoint === "string") {
361
- endpoint = provider.endpoint;
560
+ if (typeof service.endpoint === "string") {
561
+ endpoint = service.endpoint;
362
562
  } else {
363
- const { configKey } = provider.endpoint;
563
+ const { configKey } = service.endpoint;
364
564
  let base: string | null | undefined;
365
565
  let readable = true;
366
566
  try {
@@ -369,34 +569,36 @@ export async function resolveSystemOneTarget(
369
569
  : readGlobalConfig(ctx.db, configKey));
370
570
  } catch {
371
571
  readable = false;
372
- errors.push(systemOneUrlUnreadableMessage(id));
572
+ errors.push(systemOneUrlUnreadableMessage(id, capability));
373
573
  }
374
574
  if (readable) {
375
575
  if (!base || base.trim() === "") {
376
- errors.push(systemOneUrlMissingMessage(id));
576
+ errors.push(systemOneUrlMissingMessage(id, capability));
377
577
  } else {
378
- const resolved = isBuiltEndpoint(provider.endpoint)
379
- ? provider.endpoint.build(base, overrides.model ?? provider.defaultModel ?? "")
380
- : systemOneEndpointFromBase(base, provider.endpoint.path);
578
+ const resolved = isBuiltEndpoint(service.endpoint)
579
+ ? service.endpoint.build(base, overrides.model ?? service.defaultModel ?? "")
580
+ : systemOneEndpointFromBase(base, service.endpoint.path);
381
581
  if (resolved) endpoint = resolved;
382
- else errors.push(systemOneUrlInvalidMessage(id));
582
+ else errors.push(systemOneUrlInvalidMessage(id, capability));
383
583
  }
384
584
  }
385
585
  }
386
586
 
387
- let apiKey: string | null | undefined;
587
+ let read: SystemOneCredential | string | null | undefined;
388
588
  let keyProblem: string | undefined;
389
589
  try {
390
- apiKey = await (overrides.apiKey ? overrides.apiKey(id) : provider.readKey(ctx));
590
+ read = await (overrides.apiKey ? overrides.apiKey(id) : host.readKey(ctx));
391
591
  } catch {
392
592
  keyProblem = systemOneKeyUnreadableMessage(id);
393
593
  }
594
+ const apiKey = typeof read === "string" ? read : read?.value;
595
+ const keyName = (typeof read === "object" && read?.keyName) || host.keyName;
394
596
  if (!keyProblem) {
395
597
  if (!apiKey || apiKey.trim() === "") keyProblem = systemOneKeyMissingMessage(id);
396
- else if (hasWhitespaceOrControl(apiKey)) keyProblem = systemOneKeyMalformedMessage(id);
598
+ else if (hasWhitespaceOrControl(apiKey)) keyProblem = systemOneKeyMalformedMessage(id, keyName);
397
599
  }
398
600
  if (keyProblem) errors.push(keyProblem);
399
601
 
400
602
  if (errors.length > 0 || !endpoint || !apiKey) return { ok: false, error: errors.join(" ") };
401
- return { ok: true, apiKey, endpoint };
603
+ return { ok: true, apiKey, endpoint, keyName };
402
604
  }