@harperfast/harper 5.2.0 → 5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/bin/copyDb.ts +40 -11
  2. package/bin/harper.ts +20 -51
  3. package/bin/help.ts +216 -0
  4. package/components/Application.ts +236 -46
  5. package/components/ApplicationScope.ts +26 -0
  6. package/components/EntryHandler.ts +410 -105
  7. package/components/RuntimeModuleTracker.ts +189 -0
  8. package/components/Scope.ts +68 -24
  9. package/components/componentLoader.ts +70 -16
  10. package/components/deployLifecycle.ts +119 -33
  11. package/components/deploymentRecorder.ts +102 -11
  12. package/components/mcp/tools/operations.ts +6 -0
  13. package/components/operations.js +8 -6
  14. package/config/configUtils.ts +78 -20
  15. package/config/harperConfigEnvVars.ts +114 -17
  16. package/config-root.schema.json +10 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
  18. package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  19. package/dataLayer/hdbInfoController.ts +8 -0
  20. package/dataLayer/schemaDescribe.ts +2 -1
  21. package/dist/bin/copyDb.js +33 -9
  22. package/dist/bin/copyDb.js.map +1 -1
  23. package/dist/bin/harper.d.ts +6 -0
  24. package/dist/bin/harper.js +18 -50
  25. package/dist/bin/harper.js.map +1 -1
  26. package/dist/bin/help.d.ts +8 -0
  27. package/dist/bin/help.js +192 -0
  28. package/dist/bin/help.js.map +1 -0
  29. package/dist/components/Application.d.ts +16 -1
  30. package/dist/components/Application.js +210 -38
  31. package/dist/components/Application.js.map +1 -1
  32. package/dist/components/ApplicationScope.d.ts +7 -0
  33. package/dist/components/ApplicationScope.js +22 -0
  34. package/dist/components/ApplicationScope.js.map +1 -1
  35. package/dist/components/EntryHandler.d.ts +4 -4
  36. package/dist/components/EntryHandler.js +386 -95
  37. package/dist/components/EntryHandler.js.map +1 -1
  38. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  39. package/dist/components/RuntimeModuleTracker.js +189 -0
  40. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  41. package/dist/components/Scope.d.ts +1 -0
  42. package/dist/components/Scope.js +69 -22
  43. package/dist/components/Scope.js.map +1 -1
  44. package/dist/components/componentLoader.js +69 -10
  45. package/dist/components/componentLoader.js.map +1 -1
  46. package/dist/components/deployLifecycle.d.ts +6 -2
  47. package/dist/components/deployLifecycle.js +109 -31
  48. package/dist/components/deployLifecycle.js.map +1 -1
  49. package/dist/components/deploymentRecorder.d.ts +6 -0
  50. package/dist/components/deploymentRecorder.js +94 -12
  51. package/dist/components/deploymentRecorder.js.map +1 -1
  52. package/dist/components/mcp/tools/operations.js +6 -0
  53. package/dist/components/mcp/tools/operations.js.map +1 -1
  54. package/dist/components/operations.js +8 -6
  55. package/dist/components/operations.js.map +1 -1
  56. package/dist/config/configUtils.d.ts +6 -0
  57. package/dist/config/configUtils.js +78 -18
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/harperConfigEnvVars.js +108 -16
  60. package/dist/config/harperConfigEnvVars.js.map +1 -1
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
  62. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  63. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  64. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
  65. package/dist/dataLayer/hdbInfoController.js +4 -0
  66. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  67. package/dist/dataLayer/schemaDescribe.js +2 -1
  68. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  69. package/dist/resources/DatabaseTransaction.d.ts +57 -0
  70. package/dist/resources/DatabaseTransaction.js +284 -104
  71. package/dist/resources/DatabaseTransaction.js.map +1 -1
  72. package/dist/resources/ResourceInterface.d.ts +8 -2
  73. package/dist/resources/ResourceInterface.js.map +1 -1
  74. package/dist/resources/Resources.js +22 -4
  75. package/dist/resources/Resources.js.map +1 -1
  76. package/dist/resources/Table.d.ts +8 -6
  77. package/dist/resources/Table.js +158 -31
  78. package/dist/resources/Table.js.map +1 -1
  79. package/dist/resources/analytics/write.js +6 -6
  80. package/dist/resources/analytics/write.js.map +1 -1
  81. package/dist/resources/auditStore.js +27 -1
  82. package/dist/resources/auditStore.js.map +1 -1
  83. package/dist/resources/blob.d.ts +0 -1
  84. package/dist/resources/blob.js +15 -7
  85. package/dist/resources/blob.js.map +1 -1
  86. package/dist/resources/databases.d.ts +22 -5
  87. package/dist/resources/databases.js +118 -9
  88. package/dist/resources/databases.js.map +1 -1
  89. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
  90. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
  91. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  92. package/dist/resources/jsResource.d.ts +4 -26
  93. package/dist/resources/jsResource.js +5 -59
  94. package/dist/resources/jsResource.js.map +1 -1
  95. package/dist/resources/models/Models.d.ts +11 -1
  96. package/dist/resources/models/Models.js +10 -1
  97. package/dist/resources/models/Models.js.map +1 -1
  98. package/dist/resources/models/backendRegistry.d.ts +9 -0
  99. package/dist/resources/models/backendRegistry.js +10 -0
  100. package/dist/resources/models/backendRegistry.js.map +1 -1
  101. package/dist/resources/models/openaiStream.d.ts +16 -1
  102. package/dist/resources/models/openaiStream.js +113 -21
  103. package/dist/resources/models/openaiStream.js.map +1 -1
  104. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  105. package/dist/resources/models/v1/chatCompletions.js +115 -0
  106. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  107. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  108. package/dist/resources/models/v1/embeddings.js +71 -0
  109. package/dist/resources/models/v1/embeddings.js.map +1 -0
  110. package/dist/resources/models/v1/errors.d.ts +54 -0
  111. package/dist/resources/models/v1/errors.js +130 -0
  112. package/dist/resources/models/v1/errors.js.map +1 -0
  113. package/dist/resources/models/v1/index.d.ts +36 -0
  114. package/dist/resources/models/v1/index.js +75 -0
  115. package/dist/resources/models/v1/index.js.map +1 -0
  116. package/dist/resources/models/v1/models.d.ts +26 -0
  117. package/dist/resources/models/v1/models.js +44 -0
  118. package/dist/resources/models/v1/models.js.map +1 -0
  119. package/dist/resources/models/v1/translation.d.ts +133 -0
  120. package/dist/resources/models/v1/translation.js +298 -0
  121. package/dist/resources/models/v1/translation.js.map +1 -0
  122. package/dist/resources/roles.d.ts +1 -1
  123. package/dist/resources/roles.js +54 -7
  124. package/dist/resources/roles.js.map +1 -1
  125. package/dist/resources/search.d.ts +1 -1
  126. package/dist/resources/search.js +7 -3
  127. package/dist/resources/search.js.map +1 -1
  128. package/dist/security/jsLoader.js +84 -33
  129. package/dist/security/jsLoader.js.map +1 -1
  130. package/dist/security/role.js +4 -0
  131. package/dist/security/role.js.map +1 -1
  132. package/dist/security/superUserGuard.d.ts +7 -0
  133. package/dist/security/superUserGuard.js +23 -0
  134. package/dist/security/superUserGuard.js.map +1 -0
  135. package/dist/security/tokenAuthentication.d.ts +0 -1
  136. package/dist/security/tokenAuthentication.js +6 -2
  137. package/dist/security/tokenAuthentication.js.map +1 -1
  138. package/dist/security/user.d.ts +6 -1
  139. package/dist/security/user.js +23 -1
  140. package/dist/security/user.js.map +1 -1
  141. package/dist/server/http.d.ts +23 -2
  142. package/dist/server/http.js +99 -12
  143. package/dist/server/http.js.map +1 -1
  144. package/dist/server/operationsServer.d.ts +0 -2
  145. package/dist/server/operationsServer.js.map +1 -1
  146. package/dist/server/serverHelpers/multipartParser.js +9 -0
  147. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  148. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  149. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  150. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  151. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  152. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  153. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  154. package/dist/server/serverHelpers/serverHandlers.js +19 -3
  155. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  156. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  157. package/dist/server/serverHelpers/serverUtilities.js +10 -7
  158. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  159. package/dist/server/static.js +57 -18
  160. package/dist/server/static.js.map +1 -1
  161. package/dist/server/storageReclamation.d.ts +17 -0
  162. package/dist/server/storageReclamation.js +90 -6
  163. package/dist/server/storageReclamation.js.map +1 -1
  164. package/dist/server/threads/manageThreads.js +8 -0
  165. package/dist/server/threads/manageThreads.js.map +1 -1
  166. package/dist/server/threads/socketRouter.js +20 -0
  167. package/dist/server/threads/socketRouter.js.map +1 -1
  168. package/dist/server/threads/threadServer.js +12 -0
  169. package/dist/server/threads/threadServer.js.map +1 -1
  170. package/dist/sqlEngine/diff/differential.js +7 -3
  171. package/dist/sqlEngine/diff/differential.js.map +1 -1
  172. package/dist/sqlEngine/parser/normalizer.js +37 -9
  173. package/dist/sqlEngine/parser/normalizer.js.map +1 -1
  174. package/dist/sqlTranslator/index.js +6 -1
  175. package/dist/sqlTranslator/index.js.map +1 -1
  176. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  177. package/dist/upgrade/upgradePrompt.js +22 -3
  178. package/dist/upgrade/upgradePrompt.js.map +1 -1
  179. package/dist/utility/environment/environmentManager.d.ts +11 -0
  180. package/dist/utility/environment/environmentManager.js +154 -0
  181. package/dist/utility/environment/environmentManager.js.map +1 -1
  182. package/dist/utility/errors/commonErrors.d.ts +1 -0
  183. package/dist/utility/errors/commonErrors.js +1 -0
  184. package/dist/utility/errors/commonErrors.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/install/installer.js +32 -0
  189. package/dist/utility/install/installer.js.map +1 -1
  190. package/dist/utility/lmdb/environmentUtility.js +2 -1
  191. package/dist/utility/lmdb/environmentUtility.js.map +1 -1
  192. package/dist/utility/logging/harper_logger.d.ts +7 -0
  193. package/dist/utility/logging/harper_logger.js +57 -24
  194. package/dist/utility/logging/harper_logger.js.map +1 -1
  195. package/npm-shrinkwrap.json +208 -198
  196. package/package.json +10 -8
  197. package/resources/DESIGN.md +1 -0
  198. package/resources/DatabaseTransaction.ts +313 -102
  199. package/resources/ResourceInterface.ts +8 -2
  200. package/resources/Resources.ts +22 -4
  201. package/resources/Table.ts +398 -259
  202. package/resources/analytics/write.ts +22 -20
  203. package/resources/auditStore.ts +32 -1
  204. package/resources/blob.ts +15 -8
  205. package/resources/databases.ts +123 -12
  206. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
  207. package/resources/jsResource.ts +5 -62
  208. package/resources/models/Models.ts +14 -1
  209. package/resources/models/backendRegistry.ts +10 -0
  210. package/resources/models/openaiStream.ts +131 -19
  211. package/resources/models/v1/chatCompletions.ts +128 -0
  212. package/resources/models/v1/embeddings.ts +70 -0
  213. package/resources/models/v1/errors.ts +141 -0
  214. package/resources/models/v1/index.ts +72 -0
  215. package/resources/models/v1/models.ts +53 -0
  216. package/resources/models/v1/translation.ts +362 -0
  217. package/resources/roles.ts +67 -7
  218. package/resources/search.ts +8 -3
  219. package/security/jsLoader.ts +84 -30
  220. package/security/role.ts +7 -0
  221. package/security/superUserGuard.ts +20 -0
  222. package/security/tokenAuthentication.ts +6 -3
  223. package/security/user.ts +26 -1
  224. package/server/DESIGN.md +45 -34
  225. package/server/http.ts +100 -13
  226. package/server/operationsServer.ts +0 -2
  227. package/server/serverHelpers/multipartParser.ts +9 -0
  228. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  229. package/server/serverHelpers/registeredOperations.ts +19 -15
  230. package/server/serverHelpers/serverHandlers.js +20 -3
  231. package/server/serverHelpers/serverUtilities.ts +10 -7
  232. package/server/static.ts +75 -20
  233. package/server/storageReclamation.ts +104 -8
  234. package/server/threads/manageThreads.js +8 -0
  235. package/server/threads/socketRouter.ts +20 -0
  236. package/server/threads/threadServer.js +11 -0
  237. package/sqlTranslator/index.ts +6 -1
  238. package/static/defaultConfig.yaml +2 -0
  239. package/studio/web/assets/{Chat-DoVWScmq.js → Chat-BMUjPklt.js} +5 -5
  240. package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-BMUjPklt.js.map} +1 -1
  241. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-Cbghuxe5.js} +5 -5
  242. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
  243. package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-CkgTQAqc.js} +2 -2
  244. package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-CkgTQAqc.js.map} +1 -1
  245. package/studio/web/assets/{applications-D03NA7wW.js → applications-WvF4NOTN.js} +13 -13
  246. package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
  247. package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
  248. package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
  249. package/studio/web/assets/index-CDeqVx71.css +1 -0
  250. package/studio/web/assets/index-Sevt3MVx.js +819 -0
  251. package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
  252. package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
  253. package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
  254. package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
  255. package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
  256. package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
  257. package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
  258. package/studio/web/assets/{notifications-0edoFTsb.js → notifications-Ap84YyQp.js} +2 -2
  259. package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-Ap84YyQp.js.map} +1 -1
  260. package/studio/web/assets/notifications-vehqyOLM.js +2 -0
  261. package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-vehqyOLM.js.map} +1 -1
  262. package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
  263. package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
  264. package/studio/web/assets/{profile-DUfEPQtx.js → profile-DzDpRLHY.js} +2 -2
  265. package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-DzDpRLHY.js.map} +1 -1
  266. package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
  267. package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
  268. package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
  269. package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
  270. package/studio/web/assets/register-BDy01-q3.js +3 -0
  271. package/studio/web/assets/register-BDy01-q3.js.map +1 -0
  272. package/studio/web/assets/register-CQHRwNP4.js +3 -0
  273. package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
  274. package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-D0aPcI3W.js} +2 -2
  275. package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
  276. package/studio/web/assets/setup-I15FAF9H.js +3 -0
  277. package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-I15FAF9H.js.map} +1 -1
  278. package/studio/web/assets/status-BF1eJshJ.js +62 -0
  279. package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
  280. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-DwSPXXhl.js} +2 -2
  281. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
  282. package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
  283. package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
  284. package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-Bu51A-VG.js} +2 -2
  285. package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-Bu51A-VG.js.map} +1 -1
  286. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-DrV1zMrN.js} +2 -2
  287. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
  288. package/studio/web/index.html +6 -7
  289. package/upgrade/upgradePrompt.ts +22 -3
  290. package/utility/environment/environmentManager.ts +152 -0
  291. package/utility/errors/commonErrors.ts +2 -0
  292. package/utility/hdbTerms.ts +2 -0
  293. package/utility/install/installer.ts +37 -0
  294. package/utility/lmdb/environmentUtility.ts +2 -1
  295. package/utility/logging/harper_logger.ts +57 -26
  296. package/studio/web/assets/applications-D03NA7wW.js.map +0 -1
  297. package/studio/web/assets/index-Bh_CNAHr.js +0 -809
  298. package/studio/web/assets/index-Bh_CNAHr.js.map +0 -1
  299. package/studio/web/assets/index-C8Mv9kHV.css +0 -1
  300. package/studio/web/assets/index.lazy-Dx3MpyDC.js +0 -14
  301. package/studio/web/assets/notifications-CwKhipK7.js +0 -2
  302. package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
  303. package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
  304. package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
  305. package/studio/web/assets/setup-B56Oz1_u.js +0 -3
  306. package/studio/web/assets/status-BAod7p3o.js +0 -62
  307. package/studio/web/assets/status-BAod7p3o.js.map +0 -1
  308. package/studio/web/assets/textarea-DhLMZ3PA.js.map +0 -1
@@ -69,41 +69,13 @@ export class ResourceLoadError extends Error {
69
69
  *
70
70
  * Once a file has been loaded it cannot be unloaded without a restart.
71
71
  *
72
- * Thus, this plugin only handle files as they are added (`add` event). All other events result in a restart request.
72
+ * Thus, this plugin only handles files as they are added (`add` event). All other events result in a restart request.
73
73
  *
74
- * A redeploy tears down and reinstalls the component's files while this scope's watcher is paused
75
- * (see `Scope`/`EntryHandler` deploy lifecycle); on resume the fresh chokidar scan re-emits every
76
- * existing file as `'add'` — including ones whose contents just changed. Treating those as plain
77
- * adds would silently re-run against the stale module cache and never flag a restart (harper#1817).
78
- * So we track which files this scope has already loaded: a re-`add` of a known file is a redeploy of
79
- * loaded code we cannot hot-swap, and is handled like a `change` — request a restart. A first-time
80
- * `add` (initial load, or a genuinely new file added at runtime) still loads without a restart.
81
- *
82
- * A redeploy that *deletes* a loaded file is a different shape of the same problem: the fresh
83
- * chokidar scan only reports what's currently on disk, so a file that's gone produces no event at
84
- * all — no re-`add`, no `unlink` — and the modified-file handling above never sees it. Left
85
- * unhandled, the deleted resource stays registered and active in memory (harper#1817 follow-up). So
86
- * we also track which files the post-redeploy scan pass reports, and once that scan's `ready` fires,
87
- * diff it against everything this scope has ever loaded: anything missing was deleted, and is
88
- * handled the same way as a modified file — request a restart.
89
- *
90
- * That diff must only run for an actual redeploy rescan, not every time `EntryHandler` emits
91
- * `ready` — it also refires after each ordinary runtime add/change once that file's read settles
92
- * (its initial-scan-complete latch never resets outside a full rescan), and diffing against that
93
- * would falsely treat every other already-loaded file as deleted. So the diff window is gated by
94
- * the scope's own `deploy:start`/`deploy:end` bracket (see `Scope`): `deploy:start` pauses the
95
- * watcher and opens the window (and is where we reset the scan-file tracking, since no file events
96
- * can land while paused), and the first `ready` afterward — the resumed watcher's fresh scan
97
- * completing — closes it and runs the diff.
74
+ * EntryHandler preserves file identity across a deploy pause/resume and emits `change` or `unlink`
75
+ * for loaded files that changed or disappeared, so those events request the required restart.
98
76
  */
99
77
  export async function handleApplication(scope: Scope) {
100
- const loadedResourceFiles = new Set<string>();
101
- // Files reported as `add` since the most recent `deploy:start`, populated only while
102
- // `awaitingPostRedeployScan` is true — see the gating note above.
103
- let currentScanFiles = new Set<string>();
104
- let awaitingPostRedeployScan = false;
105
-
106
- const entryHandler = scope.handleEntry(async function handleResourceEntry(entryEvent) {
78
+ scope.handleEntry(async function handleResourceEntry(entryEvent) {
107
79
  if (entryEvent.entryType !== 'file') {
108
80
  scope.logger.warn(
109
81
  `jsResource plugin cannot handle entry type ${entryEvent.entryType}. Modify the 'files' option in ${scope.configFilePath} to only include files.`
@@ -111,13 +83,7 @@ export async function handleApplication(scope: Scope) {
111
83
  return;
112
84
  }
113
85
 
114
- if (awaitingPostRedeployScan && entryEvent.eventType === 'add') {
115
- // Recorded unconditionally — before the loaded/re-add branch below — so the post-scan
116
- // deletion diff sees every file this scan reported, whether newly loaded or already known.
117
- currentScanFiles.add(entryEvent.absolutePath);
118
- }
119
-
120
- if (entryEvent.eventType !== 'add' || loadedResourceFiles.has(entryEvent.absolutePath)) {
86
+ if (entryEvent.eventType !== 'add') {
121
87
  scope.requestRestart();
122
88
  return;
123
89
  }
@@ -133,9 +99,6 @@ export async function handleApplication(scope: Scope) {
133
99
  scope.logger.debug?.(`Registered root resource: ${path}`);
134
100
  }
135
101
  recurseForResources(scope, resourceModule, root);
136
- // Record the load so a later re-`add` of this same file (a redeploy re-scan) is treated
137
- // as a change and requests a restart rather than silently re-serving stale cached code.
138
- loadedResourceFiles.add(entryEvent.absolutePath);
139
102
  // A JS resource that extends an exported @table is the one carrying author opt-ins
140
103
  // (`static mcpTools`/`mcpPrompts`), and it registers here — after the schema-derived
141
104
  // table class and after the MCP component's boot scan. Signal so listing surfaces
@@ -146,26 +109,6 @@ export async function handleApplication(scope: Scope) {
146
109
  throw new ResourceLoadError(entryEvent.absolutePath, error);
147
110
  }
148
111
  });
149
-
150
- // Optional chaining: a mock/test scope may not implement EventEmitter, and Scope#handleEntry
151
- // itself can return undefined (e.g. MissingDefaultFilesOptionError). In real use `scope` is
152
- // always an EventEmitter and `entryHandler` is always the EntryHandler backing this watcher.
153
- scope.on?.('deploy:start', () => {
154
- awaitingPostRedeployScan = true;
155
- currentScanFiles = new Set();
156
- });
157
-
158
- entryHandler?.on?.('ready', () => {
159
- if (!awaitingPostRedeployScan) return;
160
- awaitingPostRedeployScan = false;
161
- for (const loadedFile of loadedResourceFiles) {
162
- if (!currentScanFiles.has(loadedFile)) {
163
- // Known file that the just-completed scan never reported — deleted during the redeploy.
164
- loadedResourceFiles.delete(loadedFile);
165
- scope.requestRestart();
166
- }
167
- }
168
- });
169
112
  }
170
113
 
171
114
  function recurseForResources(scope: Scope, resourceModule: any, prefix: string) {
@@ -91,6 +91,19 @@ export class Models implements ModelsContract {
91
91
  }
92
92
 
93
93
  async embed(input: string | string[], opts: EmbedOpts = {}): Promise<Float32Array[]> {
94
+ return (await this.embedWithUsage(input, opts)).vectors;
95
+ }
96
+
97
+ /**
98
+ * `embed()` plus the result-level `usage` the winning backend reported (all
99
+ * built-in embedding backends provide it). Internal path for callers that must
100
+ * surface usage on the wire — the `/v1/embeddings` gateway — without changing
101
+ * the public `embed()` contract. Not part of the stable models API.
102
+ */
103
+ async embedWithUsage(
104
+ input: string | string[],
105
+ opts: EmbedOpts = {}
106
+ ): Promise<{ vectors: Float32Array[]; usage?: TokenUsage }> {
94
107
  const { accounting, signal } = resolveCallContext(opts.signal);
95
108
  const startedAt = performance.now();
96
109
  const resolved = resolveCandidates('embedding', opts.model, buildRequires('embed', opts.requires, false));
@@ -118,7 +131,7 @@ export class Models implements ModelsContract {
118
131
  // success row followed by a failure row from the catch (duplicate).
119
132
  if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name);
120
133
  this.#record(backend, 'embed', opts.model, accounting, undefined, result, attemptStart);
121
- return result.output;
134
+ return { vectors: result.output, usage: result.usage };
122
135
  } catch (err) {
123
136
  this.#recordFailure(backend, 'embed', opts.model, accounting, undefined, attemptStart, err);
124
137
  if (!hasError) {
@@ -39,6 +39,16 @@ export function getBackend(kind: ModelKind, logicalName: string): ModelBackend |
39
39
  return (kind === 'embedding' ? embedding : generative).get(logicalName);
40
40
  }
41
41
 
42
+ /**
43
+ * Enumerate all registrations for `kind` as `{logicalName, backend}` pairs. Used by
44
+ * `GET /v1/models` (#631) to advertise selectable model names; `logicalName` is what a
45
+ * caller passes as `opts.model`, not the backend's own `.name`.
46
+ */
47
+ export function listBackends(kind: ModelKind): Array<{ logicalName: string; backend: ModelBackend }> {
48
+ const map = kind === 'embedding' ? embedding : generative;
49
+ return [...map.entries()].map(([logicalName, backend]) => ({ logicalName, backend }));
50
+ }
51
+
42
52
  /**
43
53
  * Resolve the embedding backend mapped to `logicalName` (default: `'default'`).
44
54
  * Throws `ModelBackendNotFoundError` if no backend is mapped.
@@ -21,6 +21,68 @@ export interface OpenAIStreamOptions {
21
21
  model?: string;
22
22
  /** Reuse a caller-supplied completion id across all chunks; one is generated when omitted. */
23
23
  id?: string;
24
+ /**
25
+ * Map a mid-stream backend error to an OpenAI error body for a final `data: {error}`
26
+ * SSE frame. Lets the v1 gateway reuse its `toOpenAIError` mapping without this generic
27
+ * formatter depending on the v1 layer. When omitted, a generic server_error body is emitted.
28
+ */
29
+ formatError?: (err: unknown) => OpenAIErrorFrameBody;
30
+ }
31
+
32
+ // Bounds on per-stream tool-call assembly. The backend supplies both the call ids and the
33
+ // argument fields, and this runs on a public HTTP path, so neither can be unbounded. Overflow
34
+ // terminates the stream through the same sanitized error-frame path as any backend failure.
35
+ const MAX_TOOL_CALLS_PER_STREAM = 256;
36
+ const MAX_TOOL_ARGUMENT_KEYS = 1024;
37
+ // Cumulative serialized-character budget across the WHOLE stream's assembly (ids, names,
38
+ // and every argument value as it arrives, plus per-entry JSON syntax so the count is an
39
+ // upper bound on `JSON.stringify(arguments)`, not just the raw content). Call/key counts
40
+ // alone don't bound memory — one key can hold an arbitrarily large value, and the final
41
+ // JSON.stringify duplicates the retained allocation — so the budget is charged per delta
42
+ // (O(delta), no re-serialization of the accumulator) and monotonically: replacing an
43
+ // existing key charges the new value too, so churn cannot smuggle unbounded values under
44
+ // a stable key count. The SSE frame that flushes the calls adds only a bounded constant
45
+ // envelope per call plus string-escaping of the arguments blob (< 2x), so the frame size
46
+ // is bounded by a small multiple of this budget.
47
+ const MAX_TOOL_ASSEMBLY_CHARS = 1_048_576;
48
+
49
+ /** Signals that a stream exceeded the tool-assembly bounds; surfaced as an SSE error frame. */
50
+ class ToolAssemblyOverflowError extends Error {
51
+ statusCode = 502;
52
+ }
53
+
54
+ /**
55
+ * Merge `source` into `target`, returning the keys added and the serialized characters
56
+ * charged. Charging over-approximates `JSON.stringify(target).length`: each first add
57
+ * charges the key plus 4 chars of JSON syntax (`"key":` quotes and colon, plus the
58
+ * comma/brace share), and every assignment — replacements included — charges the
59
+ * serialized value. Since a replacement's earlier charge is never refunded, the
60
+ * cumulative total stays an upper bound on the retained serialization while keeping
61
+ * accumulation O(delta), not O(total).
62
+ */
63
+ function assignCountingNewKeys(target: object, source: object): { addedKeys: number; addedChars: number } {
64
+ let addedKeys = 0;
65
+ let addedChars = 0;
66
+ for (const key in source) {
67
+ if (!(key in target)) {
68
+ addedKeys++;
69
+ addedChars += key.length + 4;
70
+ }
71
+ const value = (source as Record<string, unknown>)[key];
72
+ // `?? ''`: JSON.stringify returns undefined for undefined/function/symbol values —
73
+ // impossible from JSON.parse but reachable from a custom backend's crafted object.
74
+ addedChars += (JSON.stringify(value) ?? '').length;
75
+ (target as Record<string, unknown>)[key] = value;
76
+ }
77
+ return { addedKeys, addedChars };
78
+ }
79
+
80
+ /** OpenAI streaming error body (`{ message, type, code, param }` under an `error` key). */
81
+ export interface OpenAIErrorFrameBody {
82
+ message: string;
83
+ type: string;
84
+ code: string | null;
85
+ param: string | null;
24
86
  }
25
87
 
26
88
  interface OpenAIToolCallDelta {
@@ -46,7 +108,7 @@ interface OpenAIChunk {
46
108
 
47
109
  /** SSE message envelope consumed by Harper's `text/event-stream` serializer. */
48
110
  export interface OpenAIStreamMessage {
49
- data: OpenAIChunk | string;
111
+ data: OpenAIChunk | { error: OpenAIErrorFrameBody } | string;
50
112
  }
51
113
 
52
114
  /**
@@ -71,7 +133,8 @@ export async function* openaiStream(
71
133
  // Emitting incremental fragments would corrupt the OpenAI client's concatenation
72
134
  // (`{"a":1}` + `{"b":2}` → invalid JSON) — Harper's already-buffered upstream model
73
135
  // means we cannot faithfully reproduce per-token argument fragments anyway.
74
- const toolAssembly = new Map<string, { index: number; name?: string; arguments: object }>();
136
+ const toolAssembly = new Map<string, { index: number; name?: string; arguments: object; argumentCount: number }>();
137
+ let assemblyChars = 0;
75
138
 
76
139
  const chunk = (delta: OpenAIDelta, finish: OpenAIFinishReason | null): OpenAIStreamMessage => ({
77
140
  data: {
@@ -83,26 +146,75 @@ export async function* openaiStream(
83
146
  },
84
147
  });
85
148
 
86
- for await (const token of tokens) {
87
- if (token.deltaContent !== undefined) {
88
- const delta: OpenAIDelta = {};
89
- if (!roleSent) {
90
- delta.role = 'assistant';
91
- roleSent = true;
149
+ try {
150
+ for await (const token of tokens) {
151
+ if (token.deltaContent !== undefined) {
152
+ const delta: OpenAIDelta = {};
153
+ if (!roleSent) {
154
+ delta.role = 'assistant';
155
+ roleSent = true;
156
+ }
157
+ delta.content = token.deltaContent;
158
+ yield chunk(delta, null);
92
159
  }
93
- delta.content = token.deltaContent;
94
- yield chunk(delta, null);
95
- }
96
- if (token.deltaToolCalls) {
97
- for (const incoming of token.deltaToolCalls) {
98
- if (!incoming.id) continue;
99
- const existing = toolAssembly.get(incoming.id) ?? { index: toolAssembly.size, arguments: {} };
100
- if (incoming.name) existing.name = incoming.name;
101
- if (incoming.arguments) existing.arguments = { ...existing.arguments, ...incoming.arguments };
102
- toolAssembly.set(incoming.id, existing);
160
+ if (token.deltaToolCalls) {
161
+ for (const incoming of token.deltaToolCalls) {
162
+ if (!incoming.id) continue;
163
+ let existing = toolAssembly.get(incoming.id);
164
+ if (!existing) {
165
+ // Cap distinct calls per stream: ids come from the backend, and an
166
+ // unbounded map on a public HTTP path is a memory risk.
167
+ if (toolAssembly.size >= MAX_TOOL_CALLS_PER_STREAM) {
168
+ throw new ToolAssemblyOverflowError(`stream exceeded ${MAX_TOOL_CALLS_PER_STREAM} tool calls`);
169
+ }
170
+ // Null-prototype: arguments come from JSON.parse, so a field literally
171
+ // named `__proto__` is an own property. Object.assign uses [[Set]], which
172
+ // on an ordinary object would hit Object.prototype's inherited `__proto__`
173
+ // setter and silently drop the field (the previous spread did not).
174
+ existing = { index: toolAssembly.size, arguments: Object.create(null), argumentCount: 0 };
175
+ toolAssembly.set(incoming.id, existing);
176
+ // + 96: the flush frame's fixed per-call envelope (index/id/type/function
177
+ // syntax and the argument object's braces), so 256 calls of envelope are
178
+ // inside the budget too, not on top of it.
179
+ assemblyChars += incoming.id.length + 96;
180
+ }
181
+ if (incoming.name && incoming.name !== existing.name) {
182
+ assemblyChars += incoming.name.length;
183
+ existing.name = incoming.name;
184
+ }
185
+ // Guard the contract (`ToolCall.arguments` is an object): a string would be
186
+ // assigned index-wise, inflating the field count from characters.
187
+ if (incoming.arguments && typeof incoming.arguments === 'object') {
188
+ // Mutate rather than re-spread — spreading copied every previously
189
+ // accumulated property on each partial delta (O(n²) as fields grow) — and
190
+ // count only newly-introduced keys so the bound check stays O(delta) too.
191
+ const { addedKeys, addedChars } = assignCountingNewKeys(existing.arguments, incoming.arguments);
192
+ existing.argumentCount += addedKeys;
193
+ assemblyChars += addedChars;
194
+ if (existing.argumentCount > MAX_TOOL_ARGUMENT_KEYS) {
195
+ throw new ToolAssemblyOverflowError(`tool call arguments exceeded ${MAX_TOOL_ARGUMENT_KEYS} fields`);
196
+ }
197
+ }
198
+ if (assemblyChars > MAX_TOOL_ASSEMBLY_CHARS) {
199
+ throw new ToolAssemblyOverflowError(
200
+ `stream tool-call assembly exceeded ${MAX_TOOL_ASSEMBLY_CHARS} serialized characters`
201
+ );
202
+ }
203
+ }
103
204
  }
205
+ if (token.finishReason) finishReason = token.finishReason;
104
206
  }
105
- if (token.finishReason) finishReason = token.finishReason;
207
+ } catch (err) {
208
+ // The backend can throw partway through the stream (Models#wrapStream re-throws
209
+ // mid-stream backend errors). Headers/200 are already flushed, so this can't be an
210
+ // HTTP error status — emit a final OpenAI-shaped `data: {error}` frame so SDK clients
211
+ // see a parseable error (matching the non-streaming path) instead of an abrupt socket
212
+ // close. OpenAI terminates the stream on error and sends no `[DONE]`, so we do the same.
213
+ const error = opts.formatError
214
+ ? opts.formatError(err)
215
+ : { message: 'Internal server error', type: 'server_error', code: null, param: null };
216
+ yield { data: { error } };
217
+ return;
106
218
  }
107
219
 
108
220
  if (toolAssembly.size > 0) {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `POST /v1/chat/completions` — OpenAI-compatible chat endpoint (#631).
3
+ *
4
+ * SSE serving-path note: the OpenAI SDK sends `Accept: application/json` for
5
+ * ALL requests including streaming ones (`client.ts:1160` in the SDK source).
6
+ * Harper's REST layer dispatches `Accept: text/event-stream` as CONNECT, and
7
+ * everything else as the HTTP method. So `stream: true` from an OpenAI SDK
8
+ * client reaches this `post()` handler, NOT `connect()`. We detect the `stream`
9
+ * flag in the body and return `{ body: Readable }` which REST.ts bypasses
10
+ * serialisation on (REST.ts:165-193) — exactly like any SSE resource response,
11
+ * but initiated from `post()` rather than `connect()`.
12
+ */
13
+
14
+ import type { Readable } from 'node:stream';
15
+ import { contentTypes } from '../../../server/serverHelpers/contentTypes.ts';
16
+ import { Resource } from '../../Resource.ts';
17
+ import { models } from '../Models.ts';
18
+ import { openaiStream } from '../openaiStream.ts';
19
+ import { toOpenAIError, badRequest, authorizeV1Request } from './errors.ts';
20
+ import {
21
+ translateMessages,
22
+ translateTools,
23
+ toGenerateInput,
24
+ toGenerateOpts,
25
+ toChatCompletion,
26
+ validateChatRequest,
27
+ } from './translation.ts';
28
+ import type { OAIChatRequest } from './translation.ts';
29
+
30
+ type SseHandler = { serializeStream: (iterable: AsyncIterable<unknown>) => Readable };
31
+ const sseHandler = contentTypes.get('text/event-stream') as SseHandler;
32
+
33
+ // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts
34
+ export class V1ChatCompletions extends Resource {
35
+ // Reserve this fixed route: a later app registration at the same path becomes a
36
+ // loud conflict (ErrorResource) instead of silently replacing the gateway and its
37
+ // super_user gate. See Resources.set.
38
+ static reservedPath = true;
39
+
40
+ static async post(_target: unknown, body: unknown, request: unknown) {
41
+ const authError = authorizeV1Request(request as any);
42
+ if (authError) return authError;
43
+
44
+ // REST.ts passes `request.data` directly, which is the (unawaited) streaming
45
+ // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g.
46
+ // unit tests) that already pass a plain object. A malformed JSON body rejects
47
+ // this promise, which is a client error, not a 500.
48
+ try {
49
+ body = await body;
50
+ } catch (err) {
51
+ return badRequest(`Could not parse request body: ${err instanceof Error ? err.message : 'invalid JSON'}`);
52
+ }
53
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
54
+ return badRequest('Request body must be a JSON object');
55
+ }
56
+ const req = body as OAIChatRequest;
57
+
58
+ // Validate the nested wire shapes before mapping: the mappers assume well-formed
59
+ // input, so an unvalidated `messages:[null]` / `tools:[{}]` would throw a TypeError
60
+ // and surface as an RFC 9457 500 instead of an OpenAI 400.
61
+ const invalid = validateChatRequest(req);
62
+ if (invalid) return badRequest(invalid);
63
+
64
+ const model = typeof req.model === 'string' ? req.model : 'default';
65
+
66
+ try {
67
+ const messages = translateMessages(req.messages);
68
+ // tool_choice: 'none' means "do not call tools" — the only faithful way to honor
69
+ // that against a returns-tool-calls backend is to not offer the tools at all.
70
+ // 'required'/named selection are rejected in validateChatRequest.
71
+ const tools = req.tool_choice === 'none' || !req.tools?.length ? undefined : translateTools(req.tools);
72
+ const input = toGenerateInput(messages, tools);
73
+ const opts = toGenerateOpts(req);
74
+ if (req.stream) {
75
+ const tokenStream = models.generateStream(input, opts);
76
+ // serializeStream wraps the async iterable in a Node Readable so REST.ts
77
+ // can return it without re-serialising. The `body` presence on the return
78
+ // value skips REST.ts's own serialize() call (REST.ts:165-193).
79
+ // formatError reuses the non-streaming error mapping so a mid-stream backend
80
+ // failure reaches the client as an OpenAI-shaped SSE error frame.
81
+ const readable = sseHandler.serializeStream(
82
+ openaiStream(tokenStream, { model, formatError: (err) => toOpenAIError(err).data.error })
83
+ );
84
+ return {
85
+ status: 200,
86
+ headers: {
87
+ 'Content-Type': 'text/event-stream',
88
+ 'Cache-Control': 'no-cache',
89
+ 'X-Accel-Buffering': 'no',
90
+ },
91
+ body: readable,
92
+ };
93
+ }
94
+
95
+ const result = await models.generate(input, opts);
96
+ return toChatCompletion(result, model);
97
+ } catch (err) {
98
+ return toOpenAIError(err);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * A client that sends an explicit `Accept: text/event-stream` with its POST is
104
+ * dispatched by REST as CONNECT (REST.ts), not POST. The OpenAI SDK happens to send
105
+ * `Accept: application/json` even when streaming, but other valid SSE clients do not.
106
+ *
107
+ * Without this override the request reached `Resource`'s default `connect`, whose
108
+ * instance path returns `subscribe()` — an empty `IterableEventQueue` — so the client
109
+ * got a 200 SSE response that stayed open forever emitting nothing, rather than an
110
+ * error it could act on.
111
+ *
112
+ * REST passes `null` as the CONNECT body (`resource.connect(target, null, request)`),
113
+ * so the parsed body is taken off the request and handed to the same `post()`
114
+ * implementation — one code path, identical validation and error shaping.
115
+ *
116
+ * `connect` is also reachable from the WebSocket handler with a different signature
117
+ * (`resourceRequest, incomingMessages, request`), where there is no `request.data`;
118
+ * that case is rejected as a client error rather than returning an envelope the WS
119
+ * path would fail to iterate.
120
+ */
121
+ static async connect(target: unknown, _data: unknown, request: unknown) {
122
+ const data = (request as { data?: unknown })?.data;
123
+ if (data === undefined) {
124
+ return badRequest('This endpoint requires a JSON request body; WebSocket connections are not supported');
125
+ }
126
+ return this.post(target, data, request);
127
+ }
128
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `POST /v1/embeddings` — OpenAI-compatible embedding endpoint (#631).
3
+ *
4
+ * Maps OpenAI's `{ model, input }` request body to `scope.models.embed()` and
5
+ * returns `{ object: 'list', data: [...], model, usage }` per the OpenAI wire spec.
6
+ */
7
+
8
+ import { Resource } from '../../Resource.ts';
9
+ import { models } from '../Models.ts';
10
+ import { toOpenAIError, badRequest, authorizeV1Request } from './errors.ts';
11
+ import { toEmbedOpts, toEmbedResponse } from './translation.ts';
12
+
13
+ // Cap batched input, matching OpenAI's own 2048-item limit. The endpoint is
14
+ // super_user-only and off by default, so this is a sanity bound (avoid an
15
+ // unbounded fan-out to the backend), not a security control.
16
+ const MAX_EMBEDDING_INPUTS = 2048;
17
+
18
+ // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts
19
+ export class V1Embeddings extends Resource {
20
+ // Reserve this fixed route: a later app registration at the same path becomes a
21
+ // loud conflict (ErrorResource) instead of silently replacing the gateway and its
22
+ // super_user gate. See Resources.set.
23
+ static reservedPath = true;
24
+
25
+ static async post(_target: unknown, body: Record<string, unknown>, request: unknown) {
26
+ const authError = authorizeV1Request(request as any);
27
+ if (authError) return authError;
28
+
29
+ // REST.ts passes `request.data` directly, which is the (unawaited) streaming
30
+ // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g.
31
+ // unit tests) that already pass a plain object. A malformed JSON body rejects
32
+ // this promise, which is a client error, not a 500 (matches chatCompletions).
33
+ try {
34
+ body = await body;
35
+ } catch (err) {
36
+ return badRequest(`Could not parse request body: ${err instanceof Error ? err.message : 'invalid JSON'}`);
37
+ }
38
+ if (!body || typeof body !== 'object' || Array.isArray(body))
39
+ return badRequest('Request body must be a JSON object');
40
+ const raw = body as Record<string, unknown>;
41
+
42
+ // Mirrors validateChatRequest: a non-string model would silently invoke the
43
+ // configured default rather than being rejected.
44
+ if (raw.model !== undefined && typeof raw.model !== 'string') return badRequest("'model' must be a string");
45
+
46
+ const input = raw.input;
47
+ if (input === undefined || input === null) return badRequest("'input' is required");
48
+ if (typeof input !== 'string' && !Array.isArray(input)) {
49
+ return badRequest("'input' must be a string or array of strings");
50
+ }
51
+ if (Array.isArray(input) && !input.every((v) => typeof v === 'string')) {
52
+ return badRequest("'input' array elements must be strings");
53
+ }
54
+ if (Array.isArray(input) && input.length > MAX_EMBEDDING_INPUTS) {
55
+ return badRequest(`'input' array must not exceed ${MAX_EMBEDDING_INPUTS} items`);
56
+ }
57
+
58
+ const model = typeof raw.model === 'string' ? raw.model : 'default';
59
+ const opts = toEmbedOpts(raw as any);
60
+
61
+ try {
62
+ // embedWithUsage, not embed(): the public facade drops the result-level usage
63
+ // backends report, and OpenAI clients read real token counts off the response.
64
+ const { vectors, usage } = await models.embedWithUsage(input as string | string[], opts);
65
+ return toEmbedResponse(vectors, model, usage);
66
+ } catch (err) {
67
+ return toOpenAIError(err);
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * OpenAI error envelope helpers for the `/v1/*` gateway (#631).
3
+ *
4
+ * Harper's REST layer serialises uncaught errors as RFC 9457 Problem Details.
5
+ * Resources that need the OpenAI `{ error: { message, type, code, param } }`
6
+ * shape must catch errors themselves and call `toOpenAIError()` / `badRequest()`.
7
+ */
8
+
9
+ import { ModelBackendNotFoundError } from '../backendRegistry.ts';
10
+ import { ModelCapabilityError } from '../Models.ts';
11
+ import harperLogger from '../../../utility/logging/harper_logger.ts';
12
+
13
+ type OpenAIErrorType =
14
+ 'invalid_request_error' | 'server_error' | 'authentication_error' | 'permission_error' | 'api_error';
15
+
16
+ export interface OpenAIErrorBody {
17
+ message: string;
18
+ type: OpenAIErrorType;
19
+ code: string | null;
20
+ param: string | null;
21
+ }
22
+
23
+ /** HTTP response payload from a gateway error; resource methods return this directly. */
24
+ export interface OpenAIErrorResponse {
25
+ status: number;
26
+ headers: { 'Content-Type': 'application/json' };
27
+ data: { error: OpenAIErrorBody };
28
+ }
29
+
30
+ /**
31
+ * Map any thrown value to an OpenAI error envelope. Uses `statusCode` when
32
+ * present (Harper's `ClientError` / `ServerError` convention). Falls back to
33
+ * `500 server_error`. `ModelBackendNotFoundError` maps to `404 model_not_found`.
34
+ */
35
+ export function toOpenAIError(err: unknown): OpenAIErrorResponse {
36
+ let status = 500;
37
+ let type: OpenAIErrorType = 'server_error';
38
+ let code: string | null = null;
39
+
40
+ if (err instanceof ModelBackendNotFoundError) {
41
+ status = 404;
42
+ type = 'invalid_request_error';
43
+ code = 'model_not_found';
44
+ } else if (err instanceof ModelCapabilityError) {
45
+ // Caller-driven mismatch (e.g. `tools` or streaming against a backend that
46
+ // doesn't support it): the request is what's wrong, not the server. It extends
47
+ // ServerError (statusCode 500), so this must precede the statusCode branch —
48
+ // falling through would report a generic sanitized 500 for a client-actionable
49
+ // condition. The message is safe to pass through: it names only the backend and
50
+ // the capability the caller asked for.
51
+ status = 400;
52
+ type = 'invalid_request_error';
53
+ code = 'capability_unsupported';
54
+ } else if (err instanceof Error && typeof (err as any).statusCode === 'number') {
55
+ status = (err as any).statusCode;
56
+ if (status === 401) {
57
+ type = 'authentication_error';
58
+ } else if (status === 403) {
59
+ // OpenAI semantics: 401 = bad/missing credentials, 403 = valid credentials
60
+ // lacking permission (matches authorizeV1Request's own 403 envelope).
61
+ type = 'permission_error';
62
+ } else if (status < 500) {
63
+ type = 'invalid_request_error';
64
+ } else {
65
+ type = 'server_error';
66
+ }
67
+ }
68
+
69
+ // 5xx messages stay generic: internal error strings (backend stack details, file
70
+ // paths) don't belong in a wire response. The real error goes to the log. 4xx
71
+ // messages are client-actionable and pass through.
72
+ let message: string;
73
+ if (status >= 500) {
74
+ harperLogger.error('v1 gateway error', err);
75
+ message = 'Internal server error';
76
+ } else {
77
+ message = err instanceof Error ? err.message : 'Bad request';
78
+ }
79
+
80
+ return {
81
+ status,
82
+ headers: { 'Content-Type': 'application/json' },
83
+ data: { error: { message, type, code, param: null } },
84
+ };
85
+ }
86
+
87
+ /** Convenience for early request-body validation failures. */
88
+ export function badRequest(message: string): OpenAIErrorResponse {
89
+ return {
90
+ status: 400,
91
+ headers: { 'Content-Type': 'application/json' },
92
+ data: { error: { message, type: 'invalid_request_error', code: null, param: null } },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Gate for the `/v1/*` handlers, since overriding the static `get`/`post` methods
98
+ * bypasses Resource's `transactional()` wrapper and its default `allowRead`/`allowCreate`
99
+ * checks (Resource.ts:685-733, 426-435) never run for these endpoints.
100
+ *
101
+ * Mirrors Resource's default gate (super_user-only) rather than introducing a new
102
+ * permission — see PR discussion for whether a dedicated `/v1/*` permission should
103
+ * replace this later.
104
+ *
105
+ * Returns an OpenAI-shape error response when access should be denied, or `null`
106
+ * when the request may proceed.
107
+ */
108
+ export function authorizeV1Request(request: {
109
+ user?: { role?: { permission?: { super_user?: boolean } } };
110
+ }): OpenAIErrorResponse | null {
111
+ const user = request?.user;
112
+ if (!user) {
113
+ return {
114
+ status: 401,
115
+ headers: { 'Content-Type': 'application/json' },
116
+ data: {
117
+ error: {
118
+ message: 'You must provide valid credentials to access this endpoint.',
119
+ type: 'authentication_error' as const,
120
+ code: null,
121
+ param: null,
122
+ },
123
+ },
124
+ };
125
+ }
126
+ if (!user.role?.permission?.super_user) {
127
+ return {
128
+ status: 403,
129
+ headers: { 'Content-Type': 'application/json' },
130
+ data: {
131
+ error: {
132
+ message: 'You do not have permission to access this endpoint.',
133
+ type: 'permission_error' as const,
134
+ code: null,
135
+ param: null,
136
+ },
137
+ },
138
+ };
139
+ }
140
+ return null;
141
+ }