@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.
- package/bin/copyDb.ts +21 -4
- package/bin/harper.ts +20 -51
- package/bin/help.ts +216 -0
- package/components/Application.ts +236 -46
- package/components/ApplicationScope.ts +26 -0
- package/components/EntryHandler.ts +410 -105
- package/components/RuntimeModuleTracker.ts +189 -0
- package/components/Scope.ts +68 -24
- package/components/componentLoader.ts +70 -16
- package/components/deployLifecycle.ts +119 -33
- package/components/mcp/tools/operations.ts +6 -0
- package/components/operations.js +4 -6
- package/config/configUtils.ts +12 -9
- package/config-root.schema.json +10 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
- package/dataLayer/hdbInfoController.ts +8 -0
- package/dataLayer/schemaDescribe.ts +2 -1
- package/dist/bin/copyDb.js +13 -2
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/harper.d.ts +6 -0
- package/dist/bin/harper.js +18 -50
- package/dist/bin/harper.js.map +1 -1
- package/dist/bin/help.d.ts +8 -0
- package/dist/bin/help.js +192 -0
- package/dist/bin/help.js.map +1 -0
- package/dist/components/Application.d.ts +16 -1
- package/dist/components/Application.js +210 -38
- package/dist/components/Application.js.map +1 -1
- package/dist/components/ApplicationScope.d.ts +7 -0
- package/dist/components/ApplicationScope.js +22 -0
- package/dist/components/ApplicationScope.js.map +1 -1
- package/dist/components/EntryHandler.d.ts +4 -4
- package/dist/components/EntryHandler.js +386 -95
- package/dist/components/EntryHandler.js.map +1 -1
- package/dist/components/RuntimeModuleTracker.d.ts +11 -0
- package/dist/components/RuntimeModuleTracker.js +189 -0
- package/dist/components/RuntimeModuleTracker.js.map +1 -0
- package/dist/components/Scope.d.ts +1 -0
- package/dist/components/Scope.js +69 -22
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +69 -10
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deployLifecycle.d.ts +6 -2
- package/dist/components/deployLifecycle.js +109 -31
- package/dist/components/deployLifecycle.js.map +1 -1
- package/dist/components/mcp/tools/operations.js +6 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/operations.js +4 -6
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.js +13 -9
- package/dist/config/configUtils.js.map +1 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/hdbInfoController.js +4 -0
- package/dist/dataLayer/hdbInfoController.js.map +1 -1
- package/dist/dataLayer/schemaDescribe.js +2 -1
- package/dist/dataLayer/schemaDescribe.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +55 -0
- package/dist/resources/DatabaseTransaction.js +282 -103
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -2
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Resources.js +22 -4
- package/dist/resources/Resources.js.map +1 -1
- package/dist/resources/Table.d.ts +6 -5
- package/dist/resources/Table.js +92 -15
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -6
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/blob.d.ts +0 -1
- package/dist/resources/blob.js +15 -7
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +22 -5
- package/dist/resources/databases.js +118 -9
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/jsResource.d.ts +4 -26
- package/dist/resources/jsResource.js +5 -59
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -1
- package/dist/resources/models/Models.js +10 -1
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +9 -0
- package/dist/resources/models/backendRegistry.js +10 -0
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/openaiStream.d.ts +16 -1
- package/dist/resources/models/openaiStream.js +113 -21
- package/dist/resources/models/openaiStream.js.map +1 -1
- package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
- package/dist/resources/models/v1/chatCompletions.js +115 -0
- package/dist/resources/models/v1/chatCompletions.js.map +1 -0
- package/dist/resources/models/v1/embeddings.d.ts +11 -0
- package/dist/resources/models/v1/embeddings.js +71 -0
- package/dist/resources/models/v1/embeddings.js.map +1 -0
- package/dist/resources/models/v1/errors.d.ts +54 -0
- package/dist/resources/models/v1/errors.js +130 -0
- package/dist/resources/models/v1/errors.js.map +1 -0
- package/dist/resources/models/v1/index.d.ts +36 -0
- package/dist/resources/models/v1/index.js +75 -0
- package/dist/resources/models/v1/index.js.map +1 -0
- package/dist/resources/models/v1/models.d.ts +26 -0
- package/dist/resources/models/v1/models.js +44 -0
- package/dist/resources/models/v1/models.js.map +1 -0
- package/dist/resources/models/v1/translation.d.ts +133 -0
- package/dist/resources/models/v1/translation.js +298 -0
- package/dist/resources/models/v1/translation.js.map +1 -0
- package/dist/resources/roles.d.ts +1 -1
- package/dist/resources/roles.js +54 -7
- package/dist/resources/roles.js.map +1 -1
- package/dist/security/jsLoader.js +84 -33
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/role.js +4 -0
- package/dist/security/role.js.map +1 -1
- package/dist/security/superUserGuard.d.ts +7 -0
- package/dist/security/superUserGuard.js +23 -0
- package/dist/security/superUserGuard.js.map +1 -0
- package/dist/security/tokenAuthentication.d.ts +0 -1
- package/dist/security/tokenAuthentication.js +6 -2
- package/dist/security/tokenAuthentication.js.map +1 -1
- package/dist/security/user.d.ts +6 -1
- package/dist/security/user.js +23 -1
- package/dist/security/user.js.map +1 -1
- package/dist/server/http.d.ts +23 -2
- package/dist/server/http.js +99 -12
- package/dist/server/http.js.map +1 -1
- package/dist/server/operationsServer.d.ts +0 -2
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/multipartParser.js +9 -0
- package/dist/server/serverHelpers/multipartParser.js.map +1 -1
- package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
- package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
- package/dist/server/serverHelpers/registeredOperations.js +14 -13
- package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
- package/dist/server/serverHelpers/serverHandlers.js +19 -3
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +10 -7
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/static.js +57 -18
- package/dist/server/static.js.map +1 -1
- package/dist/server/storageReclamation.d.ts +17 -0
- package/dist/server/storageReclamation.js +90 -6
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/socketRouter.js +20 -0
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +12 -0
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/sqlEngine/diff/differential.js +7 -3
- package/dist/sqlEngine/diff/differential.js.map +1 -1
- package/dist/sqlTranslator/index.js +6 -1
- package/dist/sqlTranslator/index.js.map +1 -1
- package/dist/upgrade/upgradePrompt.d.ts +2 -2
- package/dist/upgrade/upgradePrompt.js +22 -3
- package/dist/upgrade/upgradePrompt.js.map +1 -1
- package/dist/utility/errors/commonErrors.d.ts +1 -0
- package/dist/utility/errors/commonErrors.js +1 -0
- package/dist/utility/errors/commonErrors.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +2 -0
- package/dist/utility/hdbTerms.js +2 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/install/installer.js +32 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +7 -0
- package/dist/utility/logging/harper_logger.js +57 -24
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/npm-shrinkwrap.json +197 -193
- package/package.json +9 -7
- package/resources/DatabaseTransaction.ts +310 -101
- package/resources/ResourceInterface.ts +8 -2
- package/resources/Resources.ts +22 -4
- package/resources/Table.ts +321 -243
- package/resources/analytics/write.ts +22 -20
- package/resources/blob.ts +15 -8
- package/resources/databases.ts +123 -12
- package/resources/jsResource.ts +5 -62
- package/resources/models/Models.ts +14 -1
- package/resources/models/backendRegistry.ts +10 -0
- package/resources/models/openaiStream.ts +131 -19
- package/resources/models/v1/chatCompletions.ts +128 -0
- package/resources/models/v1/embeddings.ts +70 -0
- package/resources/models/v1/errors.ts +141 -0
- package/resources/models/v1/index.ts +72 -0
- package/resources/models/v1/models.ts +53 -0
- package/resources/models/v1/translation.ts +362 -0
- package/resources/roles.ts +67 -7
- package/security/jsLoader.ts +84 -30
- package/security/role.ts +7 -0
- package/security/superUserGuard.ts +20 -0
- package/security/tokenAuthentication.ts +6 -3
- package/security/user.ts +26 -1
- package/server/DESIGN.md +45 -34
- package/server/http.ts +100 -13
- package/server/operationsServer.ts +0 -2
- package/server/serverHelpers/multipartParser.ts +9 -0
- package/server/serverHelpers/operationAuthorizationState.ts +11 -0
- package/server/serverHelpers/registeredOperations.ts +19 -15
- package/server/serverHelpers/serverHandlers.js +20 -3
- package/server/serverHelpers/serverUtilities.ts +10 -7
- package/server/static.ts +75 -20
- package/server/storageReclamation.ts +104 -8
- package/server/threads/socketRouter.ts +20 -0
- package/server/threads/threadServer.js +11 -0
- package/sqlTranslator/index.ts +6 -1
- package/static/defaultConfig.yaml +2 -0
- package/studio/web/assets/{Chat-DoVWScmq.js → Chat-aApwhRmz.js} +2 -2
- package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-aApwhRmz.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-sC0H91n9.js} +4 -4
- package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
- package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-DZ8JhHOI.js} +2 -2
- package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
- package/studio/web/assets/{applications-D03NA7wW.js → applications-9SEVLIO5.js} +2 -2
- package/studio/web/assets/{applications-D03NA7wW.js.map → applications-9SEVLIO5.js.map} +1 -1
- package/studio/web/assets/{index-Bh_CNAHr.js → index-Dy3uDGXb.js} +6 -6
- package/studio/web/assets/{index-Bh_CNAHr.js.map → index-Dy3uDGXb.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Dx3MpyDC.js → index.lazy-CN1zq4I4.js} +4 -4
- package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
- package/studio/web/assets/{notifications-0edoFTsb.js → notifications-BFAF07xr.js} +2 -2
- package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-BFAF07xr.js.map} +1 -1
- package/studio/web/assets/{notifications-CwKhipK7.js → notifications-CKlYVvVN.js} +2 -2
- package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-CKlYVvVN.js.map} +1 -1
- package/studio/web/assets/{profile-DUfEPQtx.js → profile-A1zhEdFG.js} +2 -2
- package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-A1zhEdFG.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-SEtBt_GV.js} +2 -2
- package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
- package/studio/web/assets/{setup-B56Oz1_u.js → setup-BisINqdH.js} +2 -2
- package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-BisINqdH.js.map} +1 -1
- package/studio/web/assets/{status-BAod7p3o.js → status-BuQoCc7l.js} +2 -2
- package/studio/web/assets/{status-BAod7p3o.js.map → status-BuQoCc7l.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-CAi_s1PC.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
- package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-DT74tlkM.js} +2 -2
- package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-DT74tlkM.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-CGRGc1n7.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/upgrade/upgradePrompt.ts +22 -3
- package/utility/errors/commonErrors.ts +2 -0
- package/utility/hdbTerms.ts +2 -0
- package/utility/install/installer.ts +37 -0
- 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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/resources/databases.ts
CHANGED
|
@@ -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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
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 ===
|
|
188
|
-
if (
|
|
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
|
-
|
|
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)
|
|
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, {
|
|
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 }
|
|
2297
|
+
const columnStore = openRocksDatabase(rootStore.path, { name: columnName });
|
|
2187
2298
|
try {
|
|
2188
2299
|
columnStore.dropSync();
|
|
2189
2300
|
} catch (error) {
|
package/resources/jsResource.ts
CHANGED
|
@@ -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
|
|
72
|
+
* Thus, this plugin only handles files as they are added (`add` event). All other events result in a restart request.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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) {
|