@harperfast/harper 5.2.0 → 5.2.1

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 (241) hide show
  1. package/bin/copyDb.ts +21 -4
  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/mcp/tools/operations.ts +6 -0
  12. package/components/operations.js +4 -6
  13. package/config/configUtils.ts +12 -9
  14. package/config-root.schema.json +10 -0
  15. package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
  16. package/dataLayer/hdbInfoController.ts +8 -0
  17. package/dataLayer/schemaDescribe.ts +2 -1
  18. package/dist/bin/copyDb.js +13 -2
  19. package/dist/bin/copyDb.js.map +1 -1
  20. package/dist/bin/harper.d.ts +6 -0
  21. package/dist/bin/harper.js +18 -50
  22. package/dist/bin/harper.js.map +1 -1
  23. package/dist/bin/help.d.ts +8 -0
  24. package/dist/bin/help.js +192 -0
  25. package/dist/bin/help.js.map +1 -0
  26. package/dist/components/Application.d.ts +16 -1
  27. package/dist/components/Application.js +210 -38
  28. package/dist/components/Application.js.map +1 -1
  29. package/dist/components/ApplicationScope.d.ts +7 -0
  30. package/dist/components/ApplicationScope.js +22 -0
  31. package/dist/components/ApplicationScope.js.map +1 -1
  32. package/dist/components/EntryHandler.d.ts +4 -4
  33. package/dist/components/EntryHandler.js +386 -95
  34. package/dist/components/EntryHandler.js.map +1 -1
  35. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  36. package/dist/components/RuntimeModuleTracker.js +189 -0
  37. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  38. package/dist/components/Scope.d.ts +1 -0
  39. package/dist/components/Scope.js +69 -22
  40. package/dist/components/Scope.js.map +1 -1
  41. package/dist/components/componentLoader.js +69 -10
  42. package/dist/components/componentLoader.js.map +1 -1
  43. package/dist/components/deployLifecycle.d.ts +6 -2
  44. package/dist/components/deployLifecycle.js +109 -31
  45. package/dist/components/deployLifecycle.js.map +1 -1
  46. package/dist/components/mcp/tools/operations.js +6 -0
  47. package/dist/components/mcp/tools/operations.js.map +1 -1
  48. package/dist/components/operations.js +4 -6
  49. package/dist/components/operations.js.map +1 -1
  50. package/dist/config/configUtils.js +13 -9
  51. package/dist/config/configUtils.js.map +1 -1
  52. package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
  53. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  54. package/dist/dataLayer/hdbInfoController.js +4 -0
  55. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  56. package/dist/dataLayer/schemaDescribe.js +2 -1
  57. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  58. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  59. package/dist/resources/DatabaseTransaction.js +282 -103
  60. package/dist/resources/DatabaseTransaction.js.map +1 -1
  61. package/dist/resources/ResourceInterface.d.ts +8 -2
  62. package/dist/resources/ResourceInterface.js.map +1 -1
  63. package/dist/resources/Resources.js +22 -4
  64. package/dist/resources/Resources.js.map +1 -1
  65. package/dist/resources/Table.d.ts +6 -5
  66. package/dist/resources/Table.js +92 -15
  67. package/dist/resources/Table.js.map +1 -1
  68. package/dist/resources/analytics/write.js +6 -6
  69. package/dist/resources/analytics/write.js.map +1 -1
  70. package/dist/resources/blob.d.ts +0 -1
  71. package/dist/resources/blob.js +15 -7
  72. package/dist/resources/blob.js.map +1 -1
  73. package/dist/resources/databases.d.ts +22 -5
  74. package/dist/resources/databases.js +118 -9
  75. package/dist/resources/databases.js.map +1 -1
  76. package/dist/resources/jsResource.d.ts +4 -26
  77. package/dist/resources/jsResource.js +5 -59
  78. package/dist/resources/jsResource.js.map +1 -1
  79. package/dist/resources/models/Models.d.ts +11 -1
  80. package/dist/resources/models/Models.js +10 -1
  81. package/dist/resources/models/Models.js.map +1 -1
  82. package/dist/resources/models/backendRegistry.d.ts +9 -0
  83. package/dist/resources/models/backendRegistry.js +10 -0
  84. package/dist/resources/models/backendRegistry.js.map +1 -1
  85. package/dist/resources/models/openaiStream.d.ts +16 -1
  86. package/dist/resources/models/openaiStream.js +113 -21
  87. package/dist/resources/models/openaiStream.js.map +1 -1
  88. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  89. package/dist/resources/models/v1/chatCompletions.js +115 -0
  90. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  91. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  92. package/dist/resources/models/v1/embeddings.js +71 -0
  93. package/dist/resources/models/v1/embeddings.js.map +1 -0
  94. package/dist/resources/models/v1/errors.d.ts +54 -0
  95. package/dist/resources/models/v1/errors.js +130 -0
  96. package/dist/resources/models/v1/errors.js.map +1 -0
  97. package/dist/resources/models/v1/index.d.ts +36 -0
  98. package/dist/resources/models/v1/index.js +75 -0
  99. package/dist/resources/models/v1/index.js.map +1 -0
  100. package/dist/resources/models/v1/models.d.ts +26 -0
  101. package/dist/resources/models/v1/models.js +44 -0
  102. package/dist/resources/models/v1/models.js.map +1 -0
  103. package/dist/resources/models/v1/translation.d.ts +133 -0
  104. package/dist/resources/models/v1/translation.js +298 -0
  105. package/dist/resources/models/v1/translation.js.map +1 -0
  106. package/dist/resources/roles.d.ts +1 -1
  107. package/dist/resources/roles.js +54 -7
  108. package/dist/resources/roles.js.map +1 -1
  109. package/dist/security/jsLoader.js +84 -33
  110. package/dist/security/jsLoader.js.map +1 -1
  111. package/dist/security/role.js +4 -0
  112. package/dist/security/role.js.map +1 -1
  113. package/dist/security/superUserGuard.d.ts +7 -0
  114. package/dist/security/superUserGuard.js +23 -0
  115. package/dist/security/superUserGuard.js.map +1 -0
  116. package/dist/security/tokenAuthentication.d.ts +0 -1
  117. package/dist/security/tokenAuthentication.js +6 -2
  118. package/dist/security/tokenAuthentication.js.map +1 -1
  119. package/dist/security/user.d.ts +6 -1
  120. package/dist/security/user.js +23 -1
  121. package/dist/security/user.js.map +1 -1
  122. package/dist/server/http.d.ts +23 -2
  123. package/dist/server/http.js +99 -12
  124. package/dist/server/http.js.map +1 -1
  125. package/dist/server/operationsServer.d.ts +0 -2
  126. package/dist/server/operationsServer.js.map +1 -1
  127. package/dist/server/serverHelpers/multipartParser.js +9 -0
  128. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  129. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  130. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  131. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  132. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  133. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  134. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  135. package/dist/server/serverHelpers/serverHandlers.js +19 -3
  136. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  137. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  138. package/dist/server/serverHelpers/serverUtilities.js +10 -7
  139. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  140. package/dist/server/static.js +57 -18
  141. package/dist/server/static.js.map +1 -1
  142. package/dist/server/storageReclamation.d.ts +17 -0
  143. package/dist/server/storageReclamation.js +90 -6
  144. package/dist/server/storageReclamation.js.map +1 -1
  145. package/dist/server/threads/socketRouter.js +20 -0
  146. package/dist/server/threads/socketRouter.js.map +1 -1
  147. package/dist/server/threads/threadServer.js +12 -0
  148. package/dist/server/threads/threadServer.js.map +1 -1
  149. package/dist/sqlEngine/diff/differential.js +7 -3
  150. package/dist/sqlEngine/diff/differential.js.map +1 -1
  151. package/dist/sqlTranslator/index.js +6 -1
  152. package/dist/sqlTranslator/index.js.map +1 -1
  153. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  154. package/dist/upgrade/upgradePrompt.js +22 -3
  155. package/dist/upgrade/upgradePrompt.js.map +1 -1
  156. package/dist/utility/errors/commonErrors.d.ts +1 -0
  157. package/dist/utility/errors/commonErrors.js +1 -0
  158. package/dist/utility/errors/commonErrors.js.map +1 -1
  159. package/dist/utility/hdbTerms.d.ts +2 -0
  160. package/dist/utility/hdbTerms.js +2 -0
  161. package/dist/utility/hdbTerms.js.map +1 -1
  162. package/dist/utility/install/installer.js +32 -0
  163. package/dist/utility/install/installer.js.map +1 -1
  164. package/dist/utility/logging/harper_logger.d.ts +7 -0
  165. package/dist/utility/logging/harper_logger.js +57 -24
  166. package/dist/utility/logging/harper_logger.js.map +1 -1
  167. package/npm-shrinkwrap.json +197 -193
  168. package/package.json +9 -7
  169. package/resources/DatabaseTransaction.ts +310 -101
  170. package/resources/ResourceInterface.ts +8 -2
  171. package/resources/Resources.ts +22 -4
  172. package/resources/Table.ts +321 -243
  173. package/resources/analytics/write.ts +22 -20
  174. package/resources/blob.ts +15 -8
  175. package/resources/databases.ts +123 -12
  176. package/resources/jsResource.ts +5 -62
  177. package/resources/models/Models.ts +14 -1
  178. package/resources/models/backendRegistry.ts +10 -0
  179. package/resources/models/openaiStream.ts +131 -19
  180. package/resources/models/v1/chatCompletions.ts +128 -0
  181. package/resources/models/v1/embeddings.ts +70 -0
  182. package/resources/models/v1/errors.ts +141 -0
  183. package/resources/models/v1/index.ts +72 -0
  184. package/resources/models/v1/models.ts +53 -0
  185. package/resources/models/v1/translation.ts +362 -0
  186. package/resources/roles.ts +67 -7
  187. package/security/jsLoader.ts +84 -30
  188. package/security/role.ts +7 -0
  189. package/security/superUserGuard.ts +20 -0
  190. package/security/tokenAuthentication.ts +6 -3
  191. package/security/user.ts +26 -1
  192. package/server/DESIGN.md +45 -34
  193. package/server/http.ts +100 -13
  194. package/server/operationsServer.ts +0 -2
  195. package/server/serverHelpers/multipartParser.ts +9 -0
  196. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  197. package/server/serverHelpers/registeredOperations.ts +19 -15
  198. package/server/serverHelpers/serverHandlers.js +20 -3
  199. package/server/serverHelpers/serverUtilities.ts +10 -7
  200. package/server/static.ts +75 -20
  201. package/server/storageReclamation.ts +104 -8
  202. package/server/threads/socketRouter.ts +20 -0
  203. package/server/threads/threadServer.js +11 -0
  204. package/sqlTranslator/index.ts +6 -1
  205. package/static/defaultConfig.yaml +2 -0
  206. package/studio/web/assets/{Chat-DoVWScmq.js → Chat-aApwhRmz.js} +2 -2
  207. package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-aApwhRmz.js.map} +1 -1
  208. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-sC0H91n9.js} +4 -4
  209. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
  210. package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-DZ8JhHOI.js} +2 -2
  211. package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
  212. package/studio/web/assets/{applications-D03NA7wW.js → applications-9SEVLIO5.js} +2 -2
  213. package/studio/web/assets/{applications-D03NA7wW.js.map → applications-9SEVLIO5.js.map} +1 -1
  214. package/studio/web/assets/{index-Bh_CNAHr.js → index-Dy3uDGXb.js} +6 -6
  215. package/studio/web/assets/{index-Bh_CNAHr.js.map → index-Dy3uDGXb.js.map} +1 -1
  216. package/studio/web/assets/{index.lazy-Dx3MpyDC.js → index.lazy-CN1zq4I4.js} +4 -4
  217. package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
  218. package/studio/web/assets/{notifications-0edoFTsb.js → notifications-BFAF07xr.js} +2 -2
  219. package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-BFAF07xr.js.map} +1 -1
  220. package/studio/web/assets/{notifications-CwKhipK7.js → notifications-CKlYVvVN.js} +2 -2
  221. package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-CKlYVvVN.js.map} +1 -1
  222. package/studio/web/assets/{profile-DUfEPQtx.js → profile-A1zhEdFG.js} +2 -2
  223. package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-A1zhEdFG.js.map} +1 -1
  224. package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-SEtBt_GV.js} +2 -2
  225. package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
  226. package/studio/web/assets/{setup-B56Oz1_u.js → setup-BisINqdH.js} +2 -2
  227. package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-BisINqdH.js.map} +1 -1
  228. package/studio/web/assets/{status-BAod7p3o.js → status-BuQoCc7l.js} +2 -2
  229. package/studio/web/assets/{status-BAod7p3o.js.map → status-BuQoCc7l.js.map} +1 -1
  230. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-CAi_s1PC.js} +2 -2
  231. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
  232. package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-DT74tlkM.js} +2 -2
  233. package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-DT74tlkM.js.map} +1 -1
  234. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-CGRGc1n7.js} +2 -2
  235. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
  236. package/studio/web/index.html +1 -1
  237. package/upgrade/upgradePrompt.ts +22 -3
  238. package/utility/errors/commonErrors.ts +2 -0
  239. package/utility/hdbTerms.ts +2 -0
  240. package/utility/install/installer.ts +37 -0
  241. package/utility/logging/harper_logger.ts +57 -26
@@ -397,26 +397,28 @@ function storeDBSizeMetrics(analyticsTable: Table, databases: Databases) {
397
397
  }
398
398
  }
399
399
 
400
- function storeVolumeMetrics(analyticsTable: Table, databases: Databases) {
401
- for (const [db, tables] of Object.entries(databases)) {
402
- try {
403
- const [firstTable] = Object.values(tables);
404
- const storageStats = firstTable?.getStorageStats();
405
- if (!storageStats) {
406
- continue;
400
+ async function storeVolumeMetrics(analyticsTable: Table, databases: Databases) {
401
+ await Promise.all(
402
+ Object.entries(databases).map(async ([db, tables]) => {
403
+ try {
404
+ const [firstTable] = Object.values(tables);
405
+ const storageStats = await firstTable?.getStorageStats();
406
+ if (!storageStats) {
407
+ return;
408
+ }
409
+ const metric = {
410
+ metric: METRIC.STORAGE_VOLUME,
411
+ database: db,
412
+ ...storageStats,
413
+ };
414
+ storeMetric(analyticsTable, metric);
415
+ log.trace?.(`db ${db} storage volume metrics: ${JSON.stringify(metric)}`);
416
+ } catch (error) {
417
+ // a table or db was deleted, could get an error here
418
+ log.warn?.(`Error getting DB volume metrics`, error);
407
419
  }
408
- const metric = {
409
- metric: METRIC.STORAGE_VOLUME,
410
- database: db,
411
- ...storageStats,
412
- };
413
- storeMetric(analyticsTable, metric);
414
- log.trace?.(`db ${db} storage volume metrics: ${JSON.stringify(metric)}`);
415
- } catch (error) {
416
- // a table or db was deleted, could get an error here
417
- log.warn?.(`Error getting DB volume metrics`, error);
418
- }
419
- }
420
+ })
421
+ );
420
422
  }
421
423
 
422
424
  // RocksDB stat names are kebab-case with a "rocksdb." prefix (e.g. "rocksdb.block.cache.hit");
@@ -1047,7 +1049,7 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
1047
1049
  storeDBSizeMetrics(analyticsTable, databases);
1048
1050
 
1049
1051
  // database storage volume metrics
1050
- storeVolumeMetrics(analyticsTable, databases);
1052
+ await storeVolumeMetrics(analyticsTable, databases);
1051
1053
 
1052
1054
  // rocksdb engine stats (only for RocksDB-backed databases)
1053
1055
  const rocksDBPeriod = lastRocksDBStatsTime ? now - lastRocksDBStatsTime : undefined;
package/resources/blob.ts CHANGED
@@ -55,7 +55,6 @@ type StorageInfo = {
55
55
  recordId?: number;
56
56
  contentBuffer?: any;
57
57
  source?: Readable;
58
- storageBuffer?: Buffer;
59
58
  compress?: boolean;
60
59
  flush?: boolean;
61
60
  start?: number;
@@ -829,7 +828,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
829
828
  storageInfoForBlob.set(slicedBlob, slicedStorageInfo);
830
829
  if (this.size != undefined)
831
830
  slicedBlob.size = (end == undefined ? this.size : Math.min(end, this.size)) - (start ?? 0);
832
- } else if (sourceStorageInfo?.contentBuffer && !sourceStorageInfo.storageBuffer) {
831
+ } else if (sourceStorageInfo?.contentBuffer) {
833
832
  const slicedStorageInfo = {
834
833
  ...sourceStorageInfo,
835
834
  contentBuffer: sourceStorageInfo.contentBuffer.subarray(start, end),
@@ -1340,8 +1339,11 @@ function getNextStorageIndex(blobStoragePaths: string[], fileId: number) {
1340
1339
  }
1341
1340
  if (((blobStoragePaths as any).lastUpdated ?? 0) + 60000 < now) {
1342
1341
  (blobStoragePaths as any).lastUpdated = now;
1343
- // create a new frequency table based on the available space
1344
- createFrequencyTableForStoragePaths(blobStoragePaths);
1342
+ // create a new frequency table based on the available space; fire-and-forget, so a
1343
+ // transient stat/mkdir failure must not become an unhandled rejection
1344
+ createFrequencyTableForStoragePaths(blobStoragePaths).catch((error) => {
1345
+ logger.warn?.('Error creating storage path frequency table', error);
1346
+ });
1345
1347
  }
1346
1348
  const nextIndex = (blobStoragePaths as any).frequencyTable[fileId % FREQUENCY_TABLE_SIZE];
1347
1349
  return nextIndex;
@@ -1356,6 +1358,10 @@ async function createFrequencyTableForStoragePaths(blobStoragePaths: string[]) {
1356
1358
  if (!statfs) return; // statfs is not available on all older node versions
1357
1359
  const availableSpaces = await Promise.all(
1358
1360
  blobStoragePaths.map(async (path) => {
1361
+ // This compares MULTIPLE distinct paths against each other, so it always needs the
1362
+ // real per-path number: quota-status.json (see getStorageSpaceStats, #1976) is a
1363
+ // single instance-wide figure that can't tell two disks apart, and would collapse
1364
+ // every path in a multi-volume STORAGE_BLOBPATHS config to the same "available" value.
1359
1365
  let stats: StatsFs;
1360
1366
  try {
1361
1367
  stats = await statfs(path);
@@ -1733,10 +1739,14 @@ addExtension({
1733
1739
  throw new Error('No store specified, cannot load blob from storage');
1734
1740
  }
1735
1741
  } else {
1742
+ // contentBuffer is a *copy* (copyingUnpacker uses copyBuffers), so it stays valid for the
1743
+ // lifetime of the blob. Do not retain the raw ext-body `buffer` here: it is a view into the
1744
+ // store's read buffer, which is recycled by later reads. Keeping it and re-emitting it on
1745
+ // re-encode (e.g. a read-modify-write / REST PATCH) serialized whatever foreign bytes had
1746
+ // since overwritten that buffer, corrupting the record (harper#2103).
1736
1747
  storageInfoForBlob.set(blob, {
1737
1748
  storageIndex: 0,
1738
1749
  fileId: null,
1739
- storageBuffer: buffer as any,
1740
1750
  contentBuffer: blobInfo[1] as any,
1741
1751
  });
1742
1752
  blob.size = blobInfo[1]?.length;
@@ -1757,9 +1767,6 @@ addExtension({
1757
1767
  if (blob.type) options.type = blob.type;
1758
1768
  if (blob.size !== undefined) options.size = blob.size;
1759
1769
  if (storageInfo) {
1760
- if (storageInfo.storageBuffer) {
1761
- return storageInfo.storageBuffer;
1762
- }
1763
1770
  if (
1764
1771
  storageInfo.contentBuffer &&
1765
1772
  (storageInfo.contentBuffer?.length < FILE_STORAGE_THRESHOLD || blob.saveInRecord)
@@ -33,7 +33,7 @@ import { handleLocalTimeForGets } from './RecordEncoder.ts';
33
33
  import { deleteRootBlobPathsForDB } from './blob.ts';
34
34
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
35
35
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
36
- import { RocksDatabase, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
36
+ import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
37
37
  import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
38
38
  import { replayLogs } from './replayLogs.ts';
39
39
  import { totalmem } from 'node:os';
@@ -98,6 +98,80 @@ const DEFAULT_DATABASE_NAME = 'data';
98
98
  const DEFINED_TABLES = Symbol('defined-tables');
99
99
  const DEFAULT_COMPRESSION_THRESHOLD = (envGet(CONFIG_PARAMS.STORAGE_PAGESIZE) || 4096) - 60; // larger than this requires multiple pages
100
100
  initSync();
101
+ /**
102
+ * The RocksDB block/blob codec for every column family this process opens (`storage.rocks.compression`),
103
+ * or `undefined` to leave rocksdb-js on its own default (lz4 wherever the native build has it).
104
+ *
105
+ * Resolved on the first open and then frozen, deliberately. RocksDB fixes a column family's codec
106
+ * for as long as it is open and rejects a reopen that disagrees, and Harper's worker threads share
107
+ * one process-wide column-family registry — so every open, in every thread, has to resolve the same
108
+ * value. Re-reading config per open does not guarantee that: on a fresh install the system families
109
+ * are created by `mountHdb()` before the config file exists, so the main thread would resolve
110
+ * nothing and the workers would resolve the configured codec, after which `__dbis__` cannot be
111
+ * reopened and Harper fails with "The system database failed to load". The installer stages this
112
+ * value before `mountHdb()` (see utility/install/installer.ts) so that first open already sees it.
113
+ *
114
+ * Unset is NOT "use the build default" for a family that already exists — see toRocksCompression.
115
+ */
116
+ let resolvedRocksCompression: string | undefined;
117
+ let rocksCompressionResolved = false;
118
+
119
+ export function getRocksCompression(): string | undefined {
120
+ if (!rocksCompressionResolved) {
121
+ resolvedRocksCompression = readDatabaseCodec();
122
+ rocksCompressionResolved = true;
123
+ }
124
+ return resolvedRocksCompression;
125
+ }
126
+
127
+ /**
128
+ * Test-only: un-freezes the resolved codec. Production code never calls this — the freeze is the
129
+ * invariant (see getRocksCompression above) — but a test process runs many unrelated test files in
130
+ * one process, so whichever file happens to open a RocksDatabase first freezes this for everyone
131
+ * after it. Tests that need to exercise config changes call this to get back to the unresolved state.
132
+ */
133
+ export function resetRocksCompression(): void {
134
+ resolvedRocksCompression = undefined;
135
+ rocksCompressionResolved = false;
136
+ }
137
+
138
+ /**
139
+ * The codec every column family in this process opens under.
140
+ *
141
+ * Compression is a deployment setting, not a per-table one. RocksDB opens all of a database's
142
+ * column families in one call, so the codec has to be decided before the first open — which is
143
+ * before Harper has read any table's metadata (that catalog is itself one of the families being
144
+ * opened). Resolving one codec from configuration and applying it to every family is what makes
145
+ * that possible; it is passed with `compressionForAllColumnFamilies` so families this process
146
+ * never names individually adopt it too, which is what lets a database created before the codec
147
+ * existed start compressing.
148
+ *
149
+ * `storage.rocks.compression` names a codec outright. Otherwise `storage.compression` (default
150
+ * true) decides enabled-or-not and the build default fills in the algorithm. Per-table metadata
151
+ * still records the LMDB-era boolean, but no longer selects: a table persisted as disabled inside
152
+ * a deployment that enables compression would need its own codec, and it cannot have one.
153
+ */
154
+ function readDatabaseCodec(): string | undefined {
155
+ const explicit = readRocksCompressionConfig();
156
+ if (explicit) return explicit;
157
+ return toRocksCompression(getDefaultCompression()) as string | undefined;
158
+ }
159
+
160
+ function readRocksCompressionConfig(): string | undefined {
161
+ const configured = envGet(CONFIG_PARAMS.STORAGE_ROCKS_COMPRESSION);
162
+ if (configured === undefined || configured === null || configured === '') return undefined;
163
+ const requested = String(configured).trim().toLowerCase();
164
+ if (!requested) return undefined;
165
+ // Rejected here rather than at the open: an unsupported name throws inside RocksDatabase.open,
166
+ // which surfaces as the system database failing to load partway through startup.
167
+ if (!supportedCompression.includes(requested)) {
168
+ throw new Error(
169
+ `storage.rocks.compression="${requested}" is not available in this build of @harperfast/rocksdb-js. Supported: ${supportedCompression.join(', ')}`
170
+ );
171
+ }
172
+ return requested;
173
+ }
174
+
101
175
  // I don't know if this is the best place for this, but somewhere we need to specify which tables
102
176
  // replicate by default:
103
177
  export const NON_REPLICATING_SYSTEM_TABLES = [
@@ -177,22 +251,51 @@ export const tables: Tables = Object.create(null);
177
251
  export const databases: Databases = Object.create(null);
178
252
 
179
253
  /**
180
- * Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata
181
- * carries values where a defined falsy value (false, '') means compression was explicitly
182
- * disabled and true/{ threshold, ... } mean enabled with defaults. rocksdb-js >= 2.6
183
- * validates `compression` strictly and treats UNSET as "use the build default (lz4)", so
184
- * disabled must be mapped to 'none' explicitly, and true to unset.
254
+ * Codec used to honor an "enabled, unspecified" compression setting, or `undefined` where the
255
+ * native build cannot provide it (in which case the request degrades to the build default rather
256
+ * than throwing).
257
+ */
258
+ const DEFAULT_ENABLED_CODEC = supportedCompression.includes('lz4') ? 'lz4' : undefined;
259
+
260
+ /**
261
+ * Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata carries
262
+ * values where a defined falsy value (false, '') means compression was explicitly disabled, and
263
+ * `true` / `{ threshold, ... }` mean enabled with defaults — `storage.compression` defaults to
264
+ * `true` (defaultConfig.yaml), so essentially every pre-existing table asked for compression.
265
+ *
266
+ * "Enabled" resolves to an explicit codec rather than to unset. Unset is not equivalent: RocksDB
267
+ * persists the codec per column family and a reopen that requests nothing inherits what the family
268
+ * already has, applying the build default only when the family does not yet exist. Leaving these
269
+ * unset therefore silently ignores the operator's request on every database created before the
270
+ * native build carried codecs — it keeps writing uncompressed forever, while a brand-new database
271
+ * gets lz4. Naming the codec makes the setting mean the same thing in both cases.
272
+ *
273
+ * This governs newly written files; existing SSTs keep their codec until write traffic rewrites
274
+ * them (`db.compact()` will not — see getRocksCompression above).
185
275
  */
186
276
  export function toRocksCompression(compression: unknown): unknown {
187
- if (compression === true) return undefined;
188
- if (compression !== undefined && !compression) return 'none';
277
+ if (compression === undefined) return undefined;
278
+ if (!compression) return 'none';
279
+ // An object carrying an explicit `algorithm` is already a rocksdb-js request; anything else
280
+ // (`true`, or an LMDB descriptor like { startingOffset, threshold }) is "enabled, unspecified".
281
+ if (compression === true || (typeof compression === 'object' && !(compression as { algorithm?: unknown }).algorithm))
282
+ return DEFAULT_ENABLED_CODEC;
189
283
  return compression;
190
284
  }
191
285
 
192
286
  function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSort?: boolean }) {
193
287
  options.disableWAL ??= true;
194
288
  const legacyOptions = options as { compression?: unknown };
195
- legacyOptions.compression = toRocksCompression(legacyOptions.compression);
289
+ // A configured codec applies to every column family, overriding whatever per-table metadata
290
+ // carries — that metadata records the LMDB-era boolean, so without this there is no way to
291
+ // select a RocksDB codec for a deployment.
292
+ // One codec for every column family, and applied to every family this open touches — not just
293
+ // the one being named. RocksDB opens them all at once and a family's codec cannot change while
294
+ // it is open, so a family this process never names individually would otherwise stay on
295
+ // whatever it was created with, forever.
296
+ const databaseCodec = getRocksCompression();
297
+ legacyOptions.compression = databaseCodec;
298
+ if (databaseCodec) (options as { compressionForAllColumnFamilies?: boolean }).compressionForAllColumnFamilies = true;
196
299
  // Apply read-only mode if enabled
197
300
  if (isReadOnlyMode()) {
198
301
  options.readOnly = true;
@@ -588,7 +691,7 @@ function initStores(
588
691
  ...internalDbiInit,
589
692
  disableWAL: false,
590
693
  name: INTERNAL_DBIS_NAME,
591
- } as any) as RocksDatabaseEx;
694
+ } as any);
592
695
  } else {
593
696
  attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
594
697
  }
@@ -1350,7 +1453,11 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1350
1453
  // Enable cache (WeakLRUCache + VT) for all custom-object index stores so the VT is
1351
1454
  // available before resolveIndexFormat decides the format. Versioned stores need the VT
1352
1455
  // for cached traversal; legacy stores pay a small per-write cache.delete() overhead only.
1353
- dbi = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiKey, cache: isCustomObjectIndex } as any) as any;
1456
+ dbi = openRocksDatabase(rootStore.path, {
1457
+ ...dbiInit,
1458
+ name: dbiKey,
1459
+ cache: isCustomObjectIndex,
1460
+ } as any) as any;
1354
1461
  (dbi as any).rootStore = rootStore;
1355
1462
  // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
1356
1463
  // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
@@ -1573,6 +1680,10 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1573
1680
  clearInterruptedDropEntries(rootStore.path, tableName);
1574
1681
  }
1575
1682
  if (rootStore instanceof RocksDatabase) {
1683
+ // Usually a genuinely new column family (existingTableMeta above found no catalog
1684
+ // entry), but an interrupted drop just completed above can leave the physical CF
1685
+ // behind under its old codec even though the catalog entry is gone — same fallback
1686
+ // as the reconcile paths covers that remnant case too.
1576
1687
  primaryStore = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiName, cache: true } as any);
1577
1688
  } else {
1578
1689
  primaryStore = (rootStore as any).openDB(dbiName, dbiInit as any);
@@ -2183,7 +2294,7 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
2183
2294
  if (rootStore instanceof RocksDatabase) {
2184
2295
  for (const columnName of (rootStore as any).columns) {
2185
2296
  if (columnName.startsWith(tableName + '/')) {
2186
- const columnStore = openRocksDatabase(rootStore.path, { name: columnName } as any);
2297
+ const columnStore = openRocksDatabase(rootStore.path, { name: columnName });
2187
2298
  try {
2188
2299
  columnStore.dropSync();
2189
2300
  } catch (error) {
@@ -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) {