@harperfast/harper 5.3.0-beta.3 → 5.3.0-beta.4
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/cliOperations.ts +25 -3
- package/bin/run.ts +18 -0
- package/components/Application.ts +52 -78
- package/components/DESIGN.md +76 -3
- package/components/Scope.ts +10 -4
- package/components/anthropic/index.ts +10 -1
- package/components/bedrock/index.ts +10 -1
- package/components/componentLoader.ts +23 -14
- package/components/mcp/tools/operations.ts +3 -1
- package/components/ollama/index.ts +10 -1
- package/components/openai/index.ts +265 -18
- package/components/operations.js +40 -1
- package/config/configUtils.ts +3 -19
- package/dataLayer/DESIGN.md +29 -5
- package/dataLayer/harperBridge/ResourceBridge.ts +39 -3
- package/dataLayer/restoreMarker.ts +250 -14
- package/dataLayer/schema.ts +2 -7
- package/dataLayer/schemaDescribe.ts +10 -0
- package/dist/bin/cliOperations.js +28 -3
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/run.js +19 -0
- package/dist/bin/run.js.map +1 -1
- package/dist/components/Application.d.ts +8 -2
- package/dist/components/Application.js +43 -69
- package/dist/components/Application.js.map +1 -1
- package/dist/components/Scope.d.ts +1 -1
- package/dist/components/Scope.js +9 -4
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/anthropic/index.d.ts +0 -10
- package/dist/components/anthropic/index.js +9 -1
- package/dist/components/anthropic/index.js.map +1 -1
- package/dist/components/bedrock/index.d.ts +0 -12
- package/dist/components/bedrock/index.js +9 -1
- package/dist/components/bedrock/index.js.map +1 -1
- package/dist/components/componentLoader.js +23 -14
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/mcp/tools/operations.js +1 -1
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/ollama/index.d.ts +0 -13
- package/dist/components/ollama/index.js +9 -1
- package/dist/components/ollama/index.js.map +1 -1
- package/dist/components/openai/index.d.ts +11 -15
- package/dist/components/openai/index.js +224 -12
- package/dist/components/openai/index.js.map +1 -1
- package/dist/components/operations.d.ts +9 -0
- package/dist/components/operations.js +32 -2
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +1 -2
- package/dist/config/configUtils.js +7 -22
- package/dist/config/configUtils.js.map +1 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.js +29 -2
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +25 -3
- package/dist/dataLayer/restoreMarker.js +213 -13
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/dataLayer/schema.js +1 -4
- package/dist/dataLayer/schema.js.map +1 -1
- package/dist/dataLayer/schemaDescribe.js +10 -0
- package/dist/dataLayer/schemaDescribe.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +28 -0
- package/dist/resources/DatabaseTransaction.d.ts +21 -1
- package/dist/resources/DatabaseTransaction.js +147 -8
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/PrimaryRocksDatabase.js +1 -1
- package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
- package/dist/resources/Table.d.ts +31 -3
- package/dist/resources/Table.js +397 -79
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/auditStore.js +16 -10
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/blob.d.ts +1 -0
- package/dist/resources/blob.js +14 -5
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/branchDatabase.js +3 -3
- package/dist/resources/branchDatabase.js.map +1 -1
- package/dist/resources/databaseDropPreparation.d.ts +20 -0
- package/dist/resources/databaseDropPreparation.js +106 -0
- package/dist/resources/databaseDropPreparation.js.map +1 -0
- package/dist/resources/databases.d.ts +26 -21
- package/dist/resources/databases.js +1040 -204
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/derivedIndexRegistry.d.ts +23 -0
- package/dist/resources/derivedIndexRegistry.js +92 -0
- package/dist/resources/derivedIndexRegistry.js.map +1 -1
- package/dist/resources/derivedIndexRuntime.d.ts +10 -0
- package/dist/resources/derivedIndexRuntime.js +136 -25
- package/dist/resources/derivedIndexRuntime.js.map +1 -1
- package/dist/resources/derivedIndexes.d.ts +39 -0
- package/dist/resources/derivedIndexes.js +733 -0
- package/dist/resources/derivedIndexes.js.map +1 -0
- package/dist/resources/fullTextSchema.d.ts +8 -0
- package/dist/resources/fullTextSchema.js +42 -0
- package/dist/resources/fullTextSchema.js.map +1 -1
- package/dist/resources/fullTextSchemaLifecycle.d.ts +0 -5
- package/dist/resources/fullTextSchemaLifecycle.js +0 -9
- package/dist/resources/fullTextSchemaLifecycle.js.map +1 -1
- package/dist/resources/graphql.js +101 -9
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/hnswDerivedIndex.d.ts +0 -10
- package/dist/resources/indexes/hnswDerivedIndex.js +0 -230
- package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -1
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +3 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +57 -8
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -1
- package/dist/resources/models/Models.d.ts +26 -3
- package/dist/resources/models/Models.js +256 -13
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/TestBackend.d.ts +3 -1
- package/dist/resources/models/TestBackend.js +35 -1
- package/dist/resources/models/TestBackend.js.map +1 -1
- package/dist/resources/models/analyticsTable.d.ts +2 -2
- package/dist/resources/models/analyticsTable.js +4 -2
- package/dist/resources/models/analyticsTable.js.map +1 -1
- package/dist/resources/models/backendHelpers.d.ts +24 -0
- package/dist/resources/models/backendHelpers.js +18 -1
- package/dist/resources/models/backendHelpers.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +10 -21
- package/dist/resources/models/backendRegistry.js +74 -18
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/bootstrap.d.ts +7 -0
- package/dist/resources/models/bootstrap.js +36 -4
- package/dist/resources/models/bootstrap.js.map +1 -1
- package/dist/resources/models/decideHook.d.ts +33 -0
- package/dist/resources/models/decideHook.js +105 -0
- package/dist/resources/models/decideHook.js.map +1 -0
- package/dist/resources/models/decision.d.ts +64 -0
- package/dist/resources/models/decision.js +507 -0
- package/dist/resources/models/decision.js.map +1 -0
- package/dist/resources/models/decisionStore.d.ts +134 -0
- package/dist/resources/models/decisionStore.js +350 -0
- package/dist/resources/models/decisionStore.js.map +1 -0
- package/dist/resources/models/embedHook.d.ts +66 -8
- package/dist/resources/models/embedHook.js +228 -61
- package/dist/resources/models/embedHook.js.map +1 -1
- package/dist/resources/models/generativeDecision.d.ts +58 -0
- package/dist/resources/models/generativeDecision.js +315 -0
- package/dist/resources/models/generativeDecision.js.map +1 -0
- package/dist/resources/models/types.d.ts +232 -4
- package/dist/security/authn/oidc/tokenExchange.js +3 -33
- package/dist/security/authn/oidc/tokenExchange.js.map +1 -1
- package/dist/security/authn/oidc/tokenUseTable.d.ts +9 -0
- package/dist/security/authn/oidc/tokenUseTable.js +28 -0
- package/dist/security/authn/oidc/tokenUseTable.js.map +1 -0
- package/dist/security/certificateVerification/certificateVerificationSource.d.ts +0 -1
- package/dist/security/certificateVerification/certificateVerificationSource.js +3 -3
- package/dist/security/certificateVerification/certificateVerificationSource.js.map +1 -1
- package/dist/security/certificateVerification/crlVerification.js +77 -165
- package/dist/security/certificateVerification/crlVerification.js.map +1 -1
- package/dist/security/certificateVerification/types.d.ts +0 -3
- package/dist/security/certificateVerification/verificationTables.d.ts +19 -0
- package/dist/security/certificateVerification/verificationTables.js +125 -0
- package/dist/security/certificateVerification/verificationTables.js.map +1 -0
- package/dist/security/certificateVerification/verificationUtils.d.ts +1 -1
- package/dist/security/certificateVerification/verificationUtils.js +6 -31
- package/dist/security/certificateVerification/verificationUtils.js.map +1 -1
- package/dist/security/jsLoader.d.ts +5 -0
- package/dist/security/jsLoader.js +8 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/Server.d.ts +1 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/server/http.js +2 -2
- package/dist/server/http.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +21 -2
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/jobs/jobOwnership.d.ts +25 -0
- package/dist/server/jobs/jobOwnership.js +127 -0
- package/dist/server/jobs/jobOwnership.js.map +1 -0
- package/dist/server/jobs/jobProcess.js +9 -2
- package/dist/server/jobs/jobProcess.js.map +1 -1
- package/dist/server/jobs/jobs.js +18 -5
- package/dist/server/jobs/jobs.js.map +1 -1
- package/dist/server/loadRootComponents.js +10 -0
- package/dist/server/loadRootComponents.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.js +11 -1
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/serverHandlers.js +12 -2
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/threads/itc.d.ts +4 -1
- package/dist/server/threads/itc.js +29 -6
- package/dist/server/threads/itc.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +12 -4
- package/dist/server/threads/manageThreads.js +241 -119
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/threadServer.d.ts +1 -0
- package/dist/server/threads/threadServer.js +15 -1
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/server/threads/windowsProcessTree.d.ts +91 -0
- package/dist/server/threads/windowsProcessTree.js +361 -0
- package/dist/server/threads/windowsProcessTree.js.map +1 -0
- package/dist/server/throttle.d.ts +1 -1
- package/dist/server/throttle.js +8 -1
- package/dist/server/throttle.js.map +1 -1
- package/dist/upgrade/directives/5-3-0.js +53 -46
- package/dist/upgrade/directives/5-3-0.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +10 -1
- package/dist/utility/errors/hdbError.js +55 -11
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/fsync.d.ts +2 -0
- package/dist/utility/fsync.js +20 -0
- package/dist/utility/fsync.js.map +1 -0
- package/dist/utility/globalSchema.d.ts +18 -0
- package/dist/utility/hdbTerms.d.ts +3 -0
- package/dist/utility/hdbTerms.js +3 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/signalling.d.ts +7 -1
- package/dist/utility/signalling.js +27 -3
- package/dist/utility/signalling.js.map +1 -1
- package/dist/utility/when.d.ts +1 -0
- package/dist/utility/when.js +21 -0
- package/dist/utility/when.js.map +1 -1
- package/dist/validation/configValidator.js +30 -3
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +13 -0
- package/json/systemSchema.json +28 -0
- package/npm-shrinkwrap.json +151 -55
- package/package.json +14 -13
- package/resources/DESIGN.md +80 -100
- package/resources/DatabaseTransaction.ts +157 -10
- package/resources/PrimaryRocksDatabase.ts +2 -2
- package/resources/Table.ts +494 -101
- package/resources/auditStore.ts +18 -10
- package/resources/blob.ts +13 -6
- package/resources/branchDatabase.ts +3 -3
- package/resources/databaseDropPreparation.ts +120 -0
- package/resources/databases.ts +1155 -205
- package/resources/derivedIndexRegistry.ts +122 -0
- package/resources/derivedIndexRuntime.ts +157 -30
- package/resources/derivedIndexes.ts +856 -0
- package/resources/fullTextSchema.ts +55 -0
- package/resources/fullTextSchemaLifecycle.ts +0 -15
- package/resources/graphql.ts +107 -12
- package/resources/indexes/DESIGN.md +7 -0
- package/resources/indexes/hnswDerivedIndex.ts +0 -260
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +72 -9
- package/resources/models/DESIGN.md +39 -0
- package/resources/models/Models.ts +361 -18
- package/resources/models/TestBackend.ts +56 -1
- package/resources/models/analyticsTable.ts +6 -4
- package/resources/models/backendHelpers.ts +17 -0
- package/resources/models/backendRegistry.ts +76 -21
- package/resources/models/bootstrap.ts +57 -7
- package/resources/models/decideHook.ts +135 -0
- package/resources/models/decision.ts +505 -0
- package/resources/models/decisionStore.ts +419 -0
- package/resources/models/embedHook.ts +272 -55
- package/resources/models/generativeDecision.ts +459 -0
- package/resources/models/types.ts +231 -4
- package/security/DESIGN.md +22 -2
- package/security/authn/oidc/tokenExchange.ts +6 -34
- package/security/authn/oidc/tokenUseTable.ts +27 -0
- package/security/certificateVerification/certificateVerificationSource.ts +2 -3
- package/security/certificateVerification/crlVerification.ts +96 -174
- package/security/certificateVerification/types.ts +0 -3
- package/security/certificateVerification/verificationTables.ts +124 -0
- package/security/certificateVerification/verificationUtils.ts +8 -32
- package/security/jsLoader.ts +9 -1
- package/server/DESIGN.md +27 -1
- package/server/Server.ts +2 -1
- package/server/http.ts +4 -2
- package/server/itc/serverHandlers.js +29 -1
- package/server/jobs/jobOwnership.ts +89 -0
- package/server/jobs/jobProcess.ts +8 -2
- package/server/jobs/jobs.ts +15 -4
- package/server/loadRootComponents.js +11 -0
- package/server/serverHelpers/contentTypes.ts +13 -1
- package/server/serverHelpers/serverHandlers.js +11 -2
- package/server/threads/itc.js +32 -6
- package/server/threads/manageThreads.js +268 -122
- package/server/threads/threadServer.js +13 -1
- package/server/threads/windowsProcessTree.ts +409 -0
- package/server/throttle.ts +11 -1
- package/studio/web/assets/{Chat-CveGbxe6.js → Chat-Bq60AO-g.js} +1 -1
- package/studio/web/assets/{FloatingChat-IpCFgYWL.js → FloatingChat-D4560T0p.js} +3 -3
- package/studio/web/assets/{apiToken-CS8fFTgL.js → apiToken-BEvbFPMc.js} +1 -1
- package/studio/web/assets/{applications-D7F4Jopl.js → applications-CSUVTH4E.js} +1 -1
- package/studio/web/assets/{cssMode-Daw_oLPq.js → cssMode-DJyc9mHj.js} +1 -1
- package/studio/web/assets/{editor-B3Bs1zWX.js → editor-DDIsQfj6.js} +1 -1
- package/studio/web/assets/{html-CDaQwWiE.js → html-g1BJHxLy.js} +1 -1
- package/studio/web/assets/{htmlMode-Cb6emO4U.js → htmlMode-Vh5IglCQ.js} +1 -1
- package/studio/web/assets/{index-B4g2Fu_2.js → index-DGRAzvO4.js} +5 -5
- package/studio/web/assets/{index.lazy-NwhvF1q1.js → index.lazy-vgSic7et.js} +1 -1
- package/studio/web/assets/{javascript-2_86gB9Q.js → javascript-5VIolqf7.js} +1 -1
- package/studio/web/assets/{jsonMode-DX-i86uQ.js → jsonMode-DG4zQraI.js} +1 -1
- package/studio/web/assets/{languageServices-B85WN-Ej.js → languageServices-t5FBpX-i.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-JCWHIsKg.js → lspLanguageFeatures-DgVnNPh-.js} +1 -1
- package/studio/web/assets/{notifications-D9ejzt52.js → notifications-BeWcX04m.js} +1 -1
- package/studio/web/assets/{notifications-B7hjKhai.js → notifications-DUGWrWoO.js} +1 -1
- package/studio/web/assets/{profile-D8B6No3F.js → profile-BvUEDBzW.js} +1 -1
- package/studio/web/assets/{regions-DFazIfTK.js → regions-CB-_2m7M.js} +1 -1
- package/studio/web/assets/{register-CwFLviLF.js → register-1w1Zr3b2.js} +2 -2
- package/studio/web/assets/{setComponentFile-VnJ80SSJ.js → setComponentFile-z4-qJCbA.js} +1 -1
- package/studio/web/assets/{setup-BYQxFy-C.js → setup-CS50uJ4x.js} +2 -2
- package/studio/web/assets/{status-Cn5XjmsL.js → status-CI0wOdOE.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-g9xIh6TX.js → toggleHighContrast-BsAE4jPe.js} +1 -1
- package/studio/web/assets/{tsMode-EC-n0Wqc.js → tsMode-D83Gg3cq.js} +1 -1
- package/studio/web/assets/{typescript-DHmM9JCO.js → typescript-Dfp-GrNq.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-BUdnVSVv.js → useEntityRestURL-BKetlINf.js} +1 -1
- package/studio/web/assets/{workers-BVl9bBB-.js → workers-CCvmDLH3.js} +1 -1
- package/studio/web/assets/{xml-C4_sqOkX.js → xml-06pZp00U.js} +1 -1
- package/studio/web/assets/{yaml-CVkbDqtW.js → yaml-iSnWMVlV.js} +1 -1
- package/studio/web/index.html +1 -1
- package/upgrade/directives/5-3-0.ts +61 -54
- package/utility/DESIGN.md +5 -1
- package/utility/errors/hdbError.ts +56 -8
- package/utility/fsync.ts +16 -0
- package/utility/hdbTerms.ts +3 -0
- package/utility/signalling.ts +33 -4
- package/utility/when.ts +22 -0
- package/validation/configValidator.ts +36 -7
|
@@ -59,6 +59,15 @@ export interface OllamaBackendConfig {
|
|
|
59
59
|
* support exists on some models but is uneven across the model catalog; we keep
|
|
60
60
|
* the v1 portability guarantee honest and skip them here.
|
|
61
61
|
*/
|
|
62
|
+
const CAPABILITIES: ModelCapabilities = Object.freeze({
|
|
63
|
+
embed: true,
|
|
64
|
+
generate: true,
|
|
65
|
+
stream: true,
|
|
66
|
+
tools: false,
|
|
67
|
+
adapters: false,
|
|
68
|
+
structuredOutput: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
62
71
|
export class OllamaBackend implements ModelBackend {
|
|
63
72
|
readonly name = 'ollama';
|
|
64
73
|
readonly #origin: string;
|
|
@@ -74,7 +83,7 @@ export class OllamaBackend implements ModelBackend {
|
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
capabilities(): ModelCapabilities {
|
|
77
|
-
return
|
|
86
|
+
return CAPABILITIES;
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
async embed(input: string | string[], opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { setEmbedding, setGenerative } from '../../resources/models/backendRegistry.ts';
|
|
20
20
|
import {
|
|
21
21
|
assignFiniteTokenCount,
|
|
22
|
+
ChoiceScoringUnsupportedError,
|
|
22
23
|
composeSignal,
|
|
23
24
|
MAX_ERROR_BODY_BYTES,
|
|
24
25
|
normalizeOrigin,
|
|
@@ -31,6 +32,7 @@ import { ServerError } from '../../utility/errors/hdbError.ts';
|
|
|
31
32
|
import harperLogger from '../../utility/logging/harper_logger.ts';
|
|
32
33
|
import type {
|
|
33
34
|
BackendOpts,
|
|
35
|
+
ChoiceScores,
|
|
34
36
|
EmbedOpts,
|
|
35
37
|
GenerateChunk,
|
|
36
38
|
GenerateInput,
|
|
@@ -40,6 +42,7 @@ import type {
|
|
|
40
42
|
ModelBackend,
|
|
41
43
|
ModelCallResult,
|
|
42
44
|
ModelCapabilities,
|
|
45
|
+
ScoreChoicesOpts,
|
|
43
46
|
ToolCall,
|
|
44
47
|
ToolDef,
|
|
45
48
|
TokenUsage,
|
|
@@ -71,6 +74,14 @@ const MAX_TOOL_CALL_ACCUMULATOR_ENTRIES = 128;
|
|
|
71
74
|
// cap (1 MiB) plus the 128-entry cap still allows ~128 MiB accumulated; this cap
|
|
72
75
|
// keeps any single stream well-bounded. Real responses use tens of KB.
|
|
73
76
|
const MAX_TOTAL_TOOL_CALL_ARGS_CHARS = 8 * 1024 * 1024; // 8 MiB
|
|
77
|
+
// `top_logprobs` accepts at most 20, so one scoring request can cover at most 20 choices with a
|
|
78
|
+
// single-letter label each; more must be declined (#2838).
|
|
79
|
+
const MAX_SCORED_CHOICES = 20;
|
|
80
|
+
const CHOICE_LABELS = 'ABCDEFGHIJKLMNOPQRST';
|
|
81
|
+
// A scoring response is one content token with at most 20 alternatives, a few KiB. The general
|
|
82
|
+
// 256 MiB cap is sized for embedding batches; an object schema scores its fields concurrently,
|
|
83
|
+
// so a faulty endpoint must not be able to hold that much per call.
|
|
84
|
+
const MAX_SCORE_RESPONSE_BODY_BYTES = 1 << 20; // 1 MiB
|
|
74
85
|
|
|
75
86
|
const log = harperLogger.forComponent('openai').conditional;
|
|
76
87
|
|
|
@@ -97,12 +108,28 @@ export interface OpenAIBackendConfig {
|
|
|
97
108
|
* - `generate` → `POST {baseUrl}/chat/completions` (always chat shape)
|
|
98
109
|
* - `generateStream` → same with `stream: true`; consumes SSE wire format and
|
|
99
110
|
* yields `GenerateChunk` per delta.
|
|
111
|
+
* - `scoreChoices` → `POST {baseUrl}/chat/completions` with `logprobs` for one
|
|
112
|
+
* token, the choices labelled `A`..`T` (#2838).
|
|
100
113
|
*
|
|
101
114
|
* Capabilities advertise `tools: true` — first backend with native tool-call
|
|
102
115
|
* support. `adapters: false` — OpenAI doesn't expose LoRA adapter selection
|
|
103
116
|
* externally. `toolMode: 'return'` (Phase 1 default) is supported end-to-end;
|
|
104
117
|
* `toolMode: 'auto'` is reserved for #612.
|
|
105
118
|
*/
|
|
119
|
+
const CAPABILITIES: ModelCapabilities = Object.freeze({
|
|
120
|
+
embed: true,
|
|
121
|
+
generate: true,
|
|
122
|
+
stream: true,
|
|
123
|
+
tools: true,
|
|
124
|
+
adapters: false,
|
|
125
|
+
scoreChoices: true,
|
|
126
|
+
structuredOutput: true,
|
|
127
|
+
maxScoredChoices: MAX_SCORED_CHOICES,
|
|
128
|
+
});
|
|
129
|
+
// The same shape for a model that refused `logprobs`; two frozen objects, so `capabilities()` allocates nothing.
|
|
130
|
+
const { maxScoredChoices: _scoringLimit, ...UNLIMITED } = CAPABILITIES;
|
|
131
|
+
const CAPABILITIES_WITHOUT_SCORING: ModelCapabilities = Object.freeze({ ...UNLIMITED, scoreChoices: false });
|
|
132
|
+
|
|
106
133
|
export class OpenAIBackend implements ModelBackend {
|
|
107
134
|
readonly name = 'openai';
|
|
108
135
|
readonly #baseUrl: string;
|
|
@@ -115,6 +142,12 @@ export class OpenAIBackend implements ModelBackend {
|
|
|
115
142
|
// (o-series, gpt-5 family) reject `max_tokens` in favour of `max_completion_tokens`;
|
|
116
143
|
// OpenAI-compatible shims (vLLM, Ollama-compat, older gateways) only know `max_tokens`.
|
|
117
144
|
readonly #isNativeOpenAI: boolean;
|
|
145
|
+
// Models this instance has seen refuse `logprobs`: a 400 naming the parameter (OpenAI's
|
|
146
|
+
// reasoning models), or an answer with no log-probability field at all (a compatible endpoint
|
|
147
|
+
// that ignores the parameter). Scoring stays off for them until a reload builds a new
|
|
148
|
+
// instance, and `capabilities()` says so for the configured model, so a decision against it
|
|
149
|
+
// votes without an attempt. An empty completion is this call's problem, not the model's.
|
|
150
|
+
readonly #logprobsRejected = new Set<string>();
|
|
118
151
|
|
|
119
152
|
constructor(config: OpenAIBackendConfig = {}, fetchImpl: typeof fetch = fetch) {
|
|
120
153
|
this.#apiKey = requireCredential(config.apiKey, 'OpenAI', 'apiKey', OpenAIBackendError);
|
|
@@ -131,7 +164,9 @@ export class OpenAIBackend implements ModelBackend {
|
|
|
131
164
|
}
|
|
132
165
|
|
|
133
166
|
capabilities(): ModelCapabilities {
|
|
134
|
-
return
|
|
167
|
+
return this.#defaultModel === undefined || !this.#logprobsRejected.has(this.#defaultModel)
|
|
168
|
+
? CAPABILITIES
|
|
169
|
+
: CAPABILITIES_WITHOUT_SCORING;
|
|
135
170
|
}
|
|
136
171
|
|
|
137
172
|
async embed(input: string | string[], opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
|
|
@@ -242,7 +277,71 @@ export class OpenAIBackend implements ModelBackend {
|
|
|
242
277
|
}
|
|
243
278
|
}
|
|
244
279
|
|
|
245
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Score `choices` from the first completion token's alternatives (#2838). The choices are
|
|
282
|
+
* listed under single-letter labels and the model is asked for the letter alone, so every
|
|
283
|
+
* choice is one token and the `top_logprobs` list can cover the whole set. What this model or
|
|
284
|
+
* endpoint cannot do is declined with `ChoiceScoringUnsupportedError`, not failed: more than
|
|
285
|
+
* 20 choices, a 400 that refuses `logprobs`, a response with no log-probabilities, no content
|
|
286
|
+
* token or no alternatives, no label among them, or too much mass outside the list to know the
|
|
287
|
+
* leading label. A malformed alternative is a failure like any other bad response.
|
|
288
|
+
*/
|
|
289
|
+
async scoreChoices(
|
|
290
|
+
input: GenerateInput,
|
|
291
|
+
choices: readonly string[],
|
|
292
|
+
opts: BackendOpts<ScoreChoicesOpts>
|
|
293
|
+
): Promise<ModelCallResult<ChoiceScores>> {
|
|
294
|
+
const model = opts.model ?? this.#defaultModel;
|
|
295
|
+
requireModel(model, 'scoreChoices', OpenAIBackendError);
|
|
296
|
+
if (choices.length < 1 || choices.length > MAX_SCORED_CHOICES)
|
|
297
|
+
throw new ChoiceScoringUnsupportedError(
|
|
298
|
+
`OpenAI scoreChoices covers 1 to ${MAX_SCORED_CHOICES} choices per call; got ${choices.length}`
|
|
299
|
+
);
|
|
300
|
+
if (this.#logprobsRejected.has(model))
|
|
301
|
+
throw new ChoiceScoringUnsupportedError(
|
|
302
|
+
`OpenAI model '${model}' rejects log-probabilities; scoring stays off for it until the backend is reloaded`
|
|
303
|
+
);
|
|
304
|
+
const body: Record<string, unknown> = {
|
|
305
|
+
model,
|
|
306
|
+
messages: [...normalizeMessages(input), { role: 'user', content: labeledChoices(choices) }],
|
|
307
|
+
stream: false,
|
|
308
|
+
logprobs: true,
|
|
309
|
+
top_logprobs: MAX_SCORED_CHOICES,
|
|
310
|
+
};
|
|
311
|
+
if (this.#isNativeOpenAI) body.max_completion_tokens = 1;
|
|
312
|
+
else body.max_tokens = 1;
|
|
313
|
+
const res = await this.#post('/chat/completions', body, opts.signal, (status, error) => {
|
|
314
|
+
if (!rejectsLogprobs(status, error)) return undefined;
|
|
315
|
+
this.#logprobsRejected.add(model);
|
|
316
|
+
return new ChoiceScoringUnsupportedError(`OpenAI model '${model}' rejects log-probabilities (HTTP ${status})`);
|
|
317
|
+
});
|
|
318
|
+
const data = await readBoundedJson<OpenAIChatResponse>(
|
|
319
|
+
res,
|
|
320
|
+
'OpenAI /chat/completions',
|
|
321
|
+
OpenAIBackendError,
|
|
322
|
+
MAX_SCORE_RESPONSE_BODY_BYTES
|
|
323
|
+
);
|
|
324
|
+
const choice = data.choices?.[0];
|
|
325
|
+
if (!choice) throw new OpenAIBackendError('OpenAI /chat/completions response missing choices[0]');
|
|
326
|
+
const usage: TokenUsage = {};
|
|
327
|
+
assignFiniteTokenCount(usage, 'promptTokens', data.usage?.prompt_tokens);
|
|
328
|
+
assignFiniteTokenCount(usage, 'completionTokens', data.usage?.completion_tokens);
|
|
329
|
+
// Only an answer that carries no log-probabilities shows the endpoint ignores the parameter;
|
|
330
|
+
// an empty or filtered completion says nothing about the model.
|
|
331
|
+
const answered = typeof choice.message?.content === 'string' && choice.message.content.length > 0;
|
|
332
|
+
const logLikelihoods = scoreLabels(choice.logprobs, choices.length, (reason, structural) => {
|
|
333
|
+
if (structural && answered) this.#logprobsRejected.add(model);
|
|
334
|
+
return new ChoiceScoringUnsupportedError(`OpenAI model '${model}' ${reason}`, usage);
|
|
335
|
+
});
|
|
336
|
+
return { status: 'completed', output: { logLikelihoods }, usage };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async #post(
|
|
340
|
+
path: string,
|
|
341
|
+
body: object,
|
|
342
|
+
callerSignal?: AbortSignal,
|
|
343
|
+
translate?: (status: number, error: OpenAIErrorEnvelope) => Error | undefined
|
|
344
|
+
): Promise<Response> {
|
|
246
345
|
const signal = composeSignal(callerSignal, this.#requestTimeoutMs);
|
|
247
346
|
const headers: Record<string, string> = {
|
|
248
347
|
'Content-Type': 'application/json',
|
|
@@ -262,35 +361,171 @@ export class OpenAIBackend implements ModelBackend {
|
|
|
262
361
|
// doesn't leak request content. Cap length defensively against a
|
|
263
362
|
// misbehaving compat shim. Falls back to status-only if the body
|
|
264
363
|
// isn't JSON or doesn't have the envelope.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
);
|
|
364
|
+
const error = await readErrorEnvelope(res);
|
|
365
|
+
const translated = translate?.(res.status, error);
|
|
366
|
+
if (translated) throw translated;
|
|
367
|
+
throw new OpenAIBackendError(`OpenAI ${path} returned HTTP ${res.status}${errorSuffix(error)}`, res.status);
|
|
269
368
|
}
|
|
270
369
|
return res;
|
|
271
370
|
}
|
|
272
371
|
}
|
|
273
372
|
|
|
274
|
-
|
|
373
|
+
interface OpenAIErrorEnvelope {
|
|
374
|
+
message?: string;
|
|
375
|
+
param?: string;
|
|
376
|
+
code?: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function readErrorEnvelope(res: Response): Promise<OpenAIErrorEnvelope> {
|
|
275
380
|
try {
|
|
276
|
-
const body = await readBoundedJson<{ error?: { message?: unknown;
|
|
381
|
+
const body = await readBoundedJson<{ error?: { message?: unknown; param?: unknown; code?: unknown } }>(
|
|
277
382
|
res,
|
|
278
383
|
'OpenAI error response',
|
|
279
384
|
OpenAIBackendError,
|
|
280
385
|
MAX_ERROR_BODY_BYTES
|
|
281
386
|
);
|
|
282
|
-
const
|
|
283
|
-
if (typeof message === 'string'
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
: message;
|
|
288
|
-
return `: ${truncated}`;
|
|
289
|
-
}
|
|
290
|
-
return '';
|
|
387
|
+
const error: OpenAIErrorEnvelope = {};
|
|
388
|
+
if (typeof body?.error?.message === 'string') error.message = body.error.message;
|
|
389
|
+
if (typeof body?.error?.param === 'string') error.param = body.error.param;
|
|
390
|
+
if (typeof body?.error?.code === 'string') error.code = body.error.code;
|
|
391
|
+
return error;
|
|
291
392
|
} catch {
|
|
292
|
-
return
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function errorSuffix(error: OpenAIErrorEnvelope): string {
|
|
398
|
+
const message = error.message;
|
|
399
|
+
if (typeof message !== 'string' || message.length === 0) return '';
|
|
400
|
+
const truncated =
|
|
401
|
+
message.length > MAX_UPSTREAM_ERROR_MESSAGE_CHARS
|
|
402
|
+
? message.slice(0, MAX_UPSTREAM_ERROR_MESSAGE_CHARS) + '…'
|
|
403
|
+
: message;
|
|
404
|
+
return `: ${truncated}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Whether a 400 says this model or endpoint does not take log-probabilities at all, as opposed to
|
|
409
|
+
* rejecting this request for another reason. OpenAI names the parameter (`param: 'logprobs'`);
|
|
410
|
+
* a compatible shim tends to say so only in the message, which must then both name the parameter
|
|
411
|
+
* and say it is unsupported, so a request-specific complaint that merely mentions it does not
|
|
412
|
+
* switch scoring off.
|
|
413
|
+
*/
|
|
414
|
+
function rejectsLogprobs(status: number, error: OpenAIErrorEnvelope): boolean {
|
|
415
|
+
if (status !== 400) return false;
|
|
416
|
+
if (error.param === 'logprobs' || error.param === 'top_logprobs') return true;
|
|
417
|
+
const message = error.message ?? '';
|
|
418
|
+
return (
|
|
419
|
+
/\b(?:top_)?logprobs\b/i.test(message) &&
|
|
420
|
+
/\b(not supported|unsupported|not available|unavailable|disabled|not allowed|does not support|doesn't support)\b/i.test(
|
|
421
|
+
message
|
|
422
|
+
)
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* One line per choice under its label. A value that cannot sit on one line as itself (a line
|
|
428
|
+
* break or other control character, leading or trailing space, a quote or backslash, or nothing
|
|
429
|
+
* at all) is JSON-quoted, so no value can pose as a further option and values that differ only in
|
|
430
|
+
* whitespace stay distinct; a plain value never starts with a quote, so the two forms cannot
|
|
431
|
+
* collide.
|
|
432
|
+
*/
|
|
433
|
+
function labeledChoices(choices: readonly string[]): string {
|
|
434
|
+
const lines = ['Choose exactly one option and reply with its letter only.'];
|
|
435
|
+
choices.forEach((choice, i) => lines.push(`${CHOICE_LABELS[i]}. ${plainOrQuoted(choice)}`));
|
|
436
|
+
return lines.join('\n');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Anything that cannot sit on one line as itself. Built from code points so the source carries
|
|
440
|
+
// no raw line terminator, which would end a regex literal.
|
|
441
|
+
const LINE_TERMINATORS = String.fromCharCode(0x2028, 0x2029);
|
|
442
|
+
const NEEDS_QUOTING = new RegExp(`^$|^\\s|\\s$|["\\\\\\x00-\\x1f\\x7f${LINE_TERMINATORS}]`);
|
|
443
|
+
const LINE_TERMINATOR = new RegExp(`[${LINE_TERMINATORS}]`, 'g');
|
|
444
|
+
|
|
445
|
+
function plainOrQuoted(choice: string): string {
|
|
446
|
+
if (!NEEDS_QUOTING.test(choice)) return choice;
|
|
447
|
+
// JSON.stringify leaves the two Unicode line terminators unescaped; they must not break the line.
|
|
448
|
+
return JSON.stringify(choice).replace(LINE_TERMINATOR, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// How far below the smallest listed alternative an unlisted label sits when the list leaves no mass
|
|
452
|
+
// over: finite, so the vector normalizes.
|
|
453
|
+
const UNLISTED_MARGIN = Math.log(1e6);
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* One log-likelihood per label from the first content token's alternatives. Spellings of the same
|
|
457
|
+
* letter (`A`, ` A`, `a.`) combine by log-sum-exp, so a label's score is the mass of its listed
|
|
458
|
+
* spellings; spellings outside the list are unknown, bounded together by the mass the list leaves
|
|
459
|
+
* over. A label with no listed spelling is placed only when that leftover is smaller than the
|
|
460
|
+
* leading observed label's mass, so a wholly unlisted label can never be the chosen value;
|
|
461
|
+
* otherwise the call is declined rather than guessed. Unlisted labels then share the leftover
|
|
462
|
+
* equally, so together they never exceed it and each stays below the leader; when the list leaves
|
|
463
|
+
* nothing over they sit a fixed margin below its smallest entry. Every placed score is still an estimate: an observed label's own unlisted
|
|
464
|
+
* spellings can add up to the leftover to it, so two observed labels closer than that may be
|
|
465
|
+
* ordered wrongly. No label at all means the model was not answering with a letter, and the call
|
|
466
|
+
* is declined. A malformed entry is a bad response, not a decline. Messages never quote tokens,
|
|
467
|
+
* which are upstream text.
|
|
468
|
+
*
|
|
469
|
+
* `unsupported(reason, structural)`: `structural` marks a response with no log-probability field
|
|
470
|
+
* at all, which the caller may remember for the model; a missing or empty alternatives list is
|
|
471
|
+
* this call's.
|
|
472
|
+
*/
|
|
473
|
+
function scoreLabels(
|
|
474
|
+
logprobs: OpenAIChatLogprobs | null | undefined,
|
|
475
|
+
count: number,
|
|
476
|
+
unsupported: (reason: string, structural: boolean) => Error
|
|
477
|
+
): number[] {
|
|
478
|
+
if (logprobs == null || logprobs.content === undefined) throw unsupported('returned no log-probabilities', true);
|
|
479
|
+
const first = Array.isArray(logprobs.content) ? logprobs.content[0] : undefined;
|
|
480
|
+
if (!first) throw unsupported('returned no scored content token', false);
|
|
481
|
+
if (!Array.isArray(first.top_logprobs) || first.top_logprobs.length === 0)
|
|
482
|
+
throw unsupported('returned no alternatives for the scored token', false);
|
|
483
|
+
const seen = new Map<string, number>();
|
|
484
|
+
for (const alternative of [first, ...first.top_logprobs]) {
|
|
485
|
+
const token = alternative?.token;
|
|
486
|
+
const logprob = alternative?.logprob;
|
|
487
|
+
if (typeof token !== 'string' || typeof logprob !== 'number' || !Number.isFinite(logprob))
|
|
488
|
+
throw new OpenAIBackendError('OpenAI /chat/completions returned a malformed log-probability entry');
|
|
489
|
+
if (!seen.has(token)) seen.set(token, logprob);
|
|
293
490
|
}
|
|
491
|
+
const scores: Array<number | undefined> = new Array(count).fill(undefined);
|
|
492
|
+
let smallestListed = Infinity;
|
|
493
|
+
let listedMass = 0;
|
|
494
|
+
for (const [token, logprob] of seen) {
|
|
495
|
+
smallestListed = Math.min(smallestListed, logprob);
|
|
496
|
+
listedMass += Math.exp(logprob);
|
|
497
|
+
const index = labelIndex(token);
|
|
498
|
+
if (index === undefined || index >= count) continue;
|
|
499
|
+
const current = scores[index];
|
|
500
|
+
scores[index] = current === undefined ? logprob : logAddExp(current, logprob);
|
|
501
|
+
}
|
|
502
|
+
const observed = scores.filter((score): score is number => score !== undefined);
|
|
503
|
+
if (observed.length === 0) throw unsupported('did not answer with one of the choice labels', false);
|
|
504
|
+
if (observed.length === count) return observed;
|
|
505
|
+
const leftover = Math.max(0, 1 - listedMass);
|
|
506
|
+
if (leftover >= Math.exp(Math.max(...observed)))
|
|
507
|
+
throw unsupported('left too much probability outside its listed alternatives to know the leading choice', false);
|
|
508
|
+
// The leftover is every token outside the list, so it bounds the unlisted labels together; each
|
|
509
|
+
// takes an equal share of it, because one token's worth understates a label whose spellings are
|
|
510
|
+
// all unlisted. A share of nothing sits a fixed margin below the list.
|
|
511
|
+
const share = leftover / (count - observed.length);
|
|
512
|
+
const floor = share > 0 ? Math.log(share) : smallestListed - UNLISTED_MARGIN;
|
|
513
|
+
return scores.map((score) => score ?? floor);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function labelIndex(token: string): number | undefined {
|
|
517
|
+
const letter = token
|
|
518
|
+
.trim()
|
|
519
|
+
.replace(/^[("'[]+|[.)\]:"',]+$/g, '')
|
|
520
|
+
.toUpperCase();
|
|
521
|
+
if (letter.length !== 1) return undefined;
|
|
522
|
+
const index = CHOICE_LABELS.indexOf(letter);
|
|
523
|
+
return index === -1 ? undefined : index;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function logAddExp(a: number, b: number): number {
|
|
527
|
+
const max = Math.max(a, b);
|
|
528
|
+
return max + Math.log(Math.exp(a - max) + Math.exp(b - max));
|
|
294
529
|
}
|
|
295
530
|
|
|
296
531
|
/**
|
|
@@ -588,10 +823,22 @@ interface OpenAIChatResponse {
|
|
|
588
823
|
choices?: Array<{
|
|
589
824
|
message?: { role: string; content?: string | null; tool_calls?: OpenAIToolCall[] };
|
|
590
825
|
finish_reason?: string | null;
|
|
826
|
+
logprobs?: OpenAIChatLogprobs | null;
|
|
591
827
|
}>;
|
|
592
828
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
593
829
|
}
|
|
594
830
|
|
|
831
|
+
interface OpenAIChatLogprobs {
|
|
832
|
+
content?: OpenAITokenLogprob[] | null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** One scored token position; fields are `unknown` because a compatible endpoint's shape is not trusted. */
|
|
836
|
+
interface OpenAITokenLogprob {
|
|
837
|
+
token?: unknown;
|
|
838
|
+
logprob?: unknown;
|
|
839
|
+
top_logprobs?: Array<{ token?: unknown; logprob?: unknown }> | null;
|
|
840
|
+
}
|
|
841
|
+
|
|
595
842
|
interface OpenAIToolCall {
|
|
596
843
|
id?: string;
|
|
597
844
|
type?: 'function';
|
package/components/operations.js
CHANGED
|
@@ -32,6 +32,7 @@ const { Resources } = require('../resources/Resources.ts');
|
|
|
32
32
|
const {
|
|
33
33
|
Application,
|
|
34
34
|
prepareApplication,
|
|
35
|
+
componentPreparationBudgetMs,
|
|
35
36
|
ASIDE_STAGING_DIR,
|
|
36
37
|
DEPLOY_STAGING_DIR,
|
|
37
38
|
dropComponentDirectory,
|
|
@@ -451,6 +452,8 @@ async function packageComponent(req) {
|
|
|
451
452
|
*
|
|
452
453
|
* A no-op on the main thread, and the operations API deploys there — so operator deploys are unvalidated
|
|
453
454
|
* (#2315 step 2). What this guarantees is ORDER: where validation runs, a rejected candidate never goes live.
|
|
455
|
+
*
|
|
456
|
+
* Also a no-op on a worker that `freeze-after-load` froze after its boot load; see components/DESIGN.md.
|
|
454
457
|
*/
|
|
455
458
|
// `componentLoader.setErrorReporter` is ONE process-global callback, so two components validating
|
|
456
459
|
// concurrently on the same worker cross-attribute their failures: B installs its reporter while A is
|
|
@@ -460,6 +463,12 @@ async function packageComponent(req) {
|
|
|
460
463
|
let validationChain = Promise.resolve();
|
|
461
464
|
|
|
462
465
|
async function validateComponentLoads(candidateDirPath, emit) {
|
|
466
|
+
if (!isMainThread && require('../security/jsLoader.ts').laterLoadsMeetFrozenIntrinsics()) {
|
|
467
|
+
log.trace(
|
|
468
|
+
`Not load-validating ${path.basename(candidateDirPath)}: this worker's intrinsics are frozen after its boot load, so the load would not match the one a restarted worker performs`
|
|
469
|
+
);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
463
472
|
const run = validationChain.then(
|
|
464
473
|
() => validateComponentLoadsExclusive(candidateDirPath, emit),
|
|
465
474
|
() => validateComponentLoadsExclusive(candidateDirPath, emit)
|
|
@@ -573,6 +582,32 @@ function logRestartOutcome(restart, what) {
|
|
|
573
582
|
`The restart after ${what} was still running after ${RESTART_WAIT_CEILING_MS}ms; worker threads may still be running the previous code`
|
|
574
583
|
);
|
|
575
584
|
}
|
|
585
|
+
|
|
586
|
+
// A peer's validation load and swap, which have no allowance of their own to sum.
|
|
587
|
+
const PEER_DEPLOY_VALIDATION_MARGIN_MS = 10 * 60 * 1000;
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* How long the origin waits for each peer to answer a replicated deploy: every wait and command the peer is
|
|
591
|
+
* allowed for this request, each at its full allowance. That includes the preparation lock's wait: a peer already
|
|
592
|
+
* preparing the same component for another deploy holds this one there for a preparation budget before the lock
|
|
593
|
+
* re-checks the holder. The lock keeps waiting while that holder is alive, so a peer queued behind a longer
|
|
594
|
+
* preparation, or behind several, can take longer; so can one validating plugins whose configured timeouts outlast
|
|
595
|
+
* the margin. Such a peer is reported as not answering, which says nothing of its outcome.
|
|
596
|
+
*/
|
|
597
|
+
function peerDeployAnswerTimeoutMs(req) {
|
|
598
|
+
const { RESTART_WAIT_CEILING_MS } = require('./awaitRestart.ts');
|
|
599
|
+
const payloadWaitMs = coerceTimeoutMs(req.deployment_timeout, DEFAULT_AWAIT_ROW_TIMEOUT_MS);
|
|
600
|
+
const installTimeoutMs = coerceTimeoutMs(req.install_timeout, undefined);
|
|
601
|
+
return Math.min(
|
|
602
|
+
payloadWaitMs * (req.credentials?.length ? 2 : 1) +
|
|
603
|
+
// the lock's wait on another deploy's preparation, then this deploy's own
|
|
604
|
+
2 * componentPreparationBudgetMs(installTimeoutMs) +
|
|
605
|
+
PEER_DEPLOY_VALIDATION_MARGIN_MS +
|
|
606
|
+
(req.restart === true ? RESTART_WAIT_CEILING_MS : 0),
|
|
607
|
+
hdbTerms.MAX_SET_TIMEOUT_MS
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
576
611
|
/**
|
|
577
612
|
* Can deploy a component in multiple ways. If a 'package' is provided all it will do is write that package to
|
|
578
613
|
* harperdb-config, when HDB is restarted the package will be installed in hdb/nodeModules. If a base64 encoded string is passed it
|
|
@@ -890,7 +925,10 @@ async function deployComponent(req) {
|
|
|
890
925
|
// finish()'s single write; live SSE 'peer' events still fire below.
|
|
891
926
|
recorder?.seal();
|
|
892
927
|
emit('phase', { phase: 'replicate', status: 'start' });
|
|
893
|
-
let response = await server.replication.replicateOperation(req, {
|
|
928
|
+
let response = await server.replication.replicateOperation(req, {
|
|
929
|
+
onPeerResult,
|
|
930
|
+
timeoutMs: peerDeployAnswerTimeoutMs(req),
|
|
931
|
+
});
|
|
894
932
|
emit('phase', { phase: 'replicate', status: 'done' });
|
|
895
933
|
if (recorder && response?.replicated) {
|
|
896
934
|
// Fallback path for replicators that don't honor onPeerResult: re-record the
|
|
@@ -1589,6 +1627,7 @@ exports.dropCustomFunctionProject = dropCustomFunctionProject;
|
|
|
1589
1627
|
exports.packageComponent = packageComponent;
|
|
1590
1628
|
exports.deployComponent = deployComponent;
|
|
1591
1629
|
exports.unconfirmedStagingPeers = unconfirmedStagingPeers;
|
|
1630
|
+
exports.peerDeployAnswerTimeoutMs = peerDeployAnswerTimeoutMs;
|
|
1592
1631
|
exports.getComponents = getComponents;
|
|
1593
1632
|
exports.getComponentFile = getComponentFile;
|
|
1594
1633
|
exports.setComponentFile = setComponentFile;
|
package/config/configUtils.ts
CHANGED
|
@@ -32,6 +32,9 @@ import * as env from '../utility/environment/environmentManager.ts';
|
|
|
32
32
|
import { prepareRuntimeEnvConfig, hasPersistedEnvConfigState, discardConfigState } from './harperConfigEnvVars.ts';
|
|
33
33
|
import { warnComponentEnvConfigVars, resolveConfiguredPath } from './componentEnvPrepass.ts';
|
|
34
34
|
import { isStartableThreadHeapMemory } from '../server/threads/threadHeapMemory.ts';
|
|
35
|
+
import { fsyncTolerantSync, isUnsupportedSyncError } from '../utility/fsync.ts';
|
|
36
|
+
|
|
37
|
+
export { isUnsupportedSyncError } from '../utility/fsync.ts';
|
|
35
38
|
|
|
36
39
|
const { DATABASES_PARAM_CONFIG, CONFIG_PARAMS, CONFIG_PARAM_MAP } = hdbTerms;
|
|
37
40
|
const UNINIT_GET_CONFIG_ERR = 'Unable to get config value because config is uninitialized';
|
|
@@ -133,25 +136,6 @@ type AtomicWriteOptions = RenameRetryOptions & {
|
|
|
133
136
|
durable?: boolean;
|
|
134
137
|
};
|
|
135
138
|
|
|
136
|
-
// Codes that mean "this platform or filesystem will not fsync this handle", as opposed to "the write did
|
|
137
|
-
// not reach storage". Windows raises EPERM fsyncing perfectly healthy files and cannot open a directory
|
|
138
|
-
// for fsync at all; network and overlay mounts return EINVAL or ENOTSUP. None of those say anything about
|
|
139
|
-
// durability, and treating them as failures would fail every durable write on those platforms. EIO and
|
|
140
|
-
// ENOSPC are not in the set on purpose.
|
|
141
|
-
const UNSUPPORTED_SYNC_CODES = new Set(['EPERM', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'EBADF', 'EISDIR']);
|
|
142
|
-
|
|
143
|
-
export function isUnsupportedSyncError(error: unknown): boolean {
|
|
144
|
-
return UNSUPPORTED_SYNC_CODES.has((error as NodeJS.ErrnoException)?.code ?? '');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function fsyncTolerantSync(fd: number) {
|
|
148
|
-
try {
|
|
149
|
-
fs.fsyncSync(fd);
|
|
150
|
-
} catch (error) {
|
|
151
|
-
if (!isUnsupportedSyncError(error)) throw error;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
139
|
// `flags` matters on Windows, which only flushes a handle opened for writing; a directory cannot be opened
|
|
156
140
|
// for writing anywhere, and Windows cannot open one for fsync at all.
|
|
157
141
|
function syncPathSync(targetPath: string, flags: 'r' | 'r+') {
|
package/dataLayer/DESIGN.md
CHANGED
|
@@ -40,6 +40,20 @@ System tables replicate by default. To opt out, add the name to `NON_REPLICATING
|
|
|
40
40
|
|
|
41
41
|
If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge.
|
|
42
42
|
|
|
43
|
+
**A replicated system table whose owner declares more than a name list** — a table-level `expiration`, an index — needs that declaration on every node, every boot. `hdb_oidc_token_use` is the case: `security/authn/oidc/tokenUseTable.ts` holds its one definition, and `bin/run.ts initialize()` applies it after the upgrade step and before worker threads start (skipped in read-only mode; a failure is logged and retried on the next start), as do the 5.3.0 directive and the exchange path. Each replay row carries its own expiry as record metadata, which replication applies on the receiving node; the table's stored `expiration` is what arms the cleanup scan that removes those rows once a node loads the table. Metadata rather than an `@expiresAt` attribute, because the attribute needs an index and a sweep of its own on every node, while reads and the cleanup scan already honor the metadata. `systemSchema.json` keeps only its primary key; the declaration completes the table on the first boot after install. Every other route to a copy leaves it partial, which is why the declaration cannot live only on the exchange path or in the directive:
|
|
44
|
+
|
|
45
|
+
- A node that has not upgraded yet gets any system table it lacks from a peer's replication handshake (`DB_SCHEMA`), created from a snapshot that carries each attribute's name, type and primary-key bit — never `indexed`, `expiresAt` or the table's `expiration` — and schema-defined like the peer's copy. From then on the handshake keeps that local definition and logs `Schema for '<db>.<table>' is defined locally, but attribute '<name>: <type>' from '<node>' does not match local attribute which does not exist` for every attribute a peer has and it lacks, so no peer repairs it; the directive that runs when that node upgrades finds the table already there.
|
|
46
|
+
- A node that never exercises the owner's code path (a passive cluster member for the OIDC exchange) never declares the rest, and the rows replicated to it are never removed.
|
|
47
|
+
- An install already on a pre-release of the directive's version never runs the directive again (the `compareVersions` caveat above): from `5.3.0-beta.2`, `getVersionsForUpgrade` selects nothing for `5.3.0`, `5.3.0-beta.3` or `5.3.1`.
|
|
48
|
+
|
|
49
|
+
A local `table()` declaration is authoritative, so repeating it repairs any of those shapes in place, keeping every row and its expiry, and is a no-op once the shape matches (measured 2026-09-25: under 1 ms; repairing a copy holding 100,000 replay rows, under 10 ms on either engine, since no row is rewritten and no index is built). Limits that come from the storage layer rather than this table:
|
|
50
|
+
|
|
51
|
+
- A thread that only loads the table scans daily (the load path keeps the default interval; only a declaring thread uses a quarter of the `expiration`), so a spent row can stay on disk up to a day past its expiry. Reads skip it meanwhile.
|
|
52
|
+
- Under `threads: 0` the main thread loads and declares the table before `startHTTPThreads` makes it worker 0. `scheduleCleanup` records an interval only once its thread has a worker index, so the first expiring write or declaration after that arms the scan; a single-threaded node that writes nothing after a restart does not scan until it does.
|
|
53
|
+
- An attribute a declaration drops keeps its index store on disk: `table()` looks that store up by table name rather than attribute name, so it never drops it. A node that served exchanges on 5.3.0-beta.2 keeps its old `expiresAt` index; after a restart nothing opens it.
|
|
54
|
+
|
|
55
|
+
The certificate verification tables (`hdb_certificate_cache`, `hdb_crl_cache`, `hdb_revoked_certificates`) follow the same pattern through `security/certificateVerification/verificationTables.ts`, applied in `initialize()` right after the OIDC table and by the verification path's getters. `ensureCertificateVerificationTables` also waits for the revoked table's index backfill and reads its outcome back from the catalog, since a failed backfill still settles. What their rows hold is in security/DESIGN.md.
|
|
56
|
+
|
|
43
57
|
## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)
|
|
44
58
|
|
|
45
59
|
The `restore_backup` operation restores a user database on a live server by closing it across all
|
|
@@ -109,11 +123,21 @@ Three non-obvious mechanics keep that safe:
|
|
|
109
123
|
exited job worker leaves no residual handle to be mistaken for a live holder.
|
|
110
124
|
- **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
|
|
111
125
|
A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
|
|
112
|
-
a "successful" restore (or vice versa). `dropDatabase`
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
a "successful" restore (or vice versa). `dropDatabase` takes the restore lock for every RocksDB or
|
|
127
|
+
LMDB root and publishes a positional `.dropping` marker beside each root before deleting any of
|
|
128
|
+
them. A restore in progress makes the acquire fail with 409; `beginRestore` likewise refuses a
|
|
129
|
+
surviving drop marker. Each marker records the root store's blob identity and exact pinned blob
|
|
130
|
+
paths, so a retry removes what an already-marked handle owned even if `storage.blobPaths` changes.
|
|
131
|
+
An integrity digest makes damaged identity content fail closed instead of redirecting deletion.
|
|
132
|
+
Markers are removed only after every root and blob path has been removed.
|
|
133
|
+
Startup scans and cold opens reject the marked root and the rest of its logical database graph, so
|
|
134
|
+
a crash while publishing or canceling several markers cannot expose a partial database. Retrying
|
|
135
|
+
`drop_database` re-enumerates that graph and completes marker publication and deletion even when
|
|
136
|
+
the in-memory catalog is gone. A configured `databases.<name>.path` is the exclusive root of that
|
|
137
|
+
logical graph, not a container for independent databases: every physical database directly beneath
|
|
138
|
+
it is an alias-owned member, and dropping any alias drops the graph. A strict worker-close failure
|
|
139
|
+
similarly keeps that worker's physical roots fenced and remembered; a same-name drop retry
|
|
140
|
+
re-attempts those closes before marker publication or deletion.
|
|
117
141
|
`database()`'s on-demand open still uses the read-only `throwIfBlockedByRestore` (a
|
|
118
142
|
`create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
|
|
119
143
|
the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import searchValidator from '../../validation/searchValidator.ts';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { threadId } from 'node:worker_threads';
|
|
2
4
|
import { handleHDBError, ClientError, hdbErrors } from '../../utility/errors/hdbError.ts';
|
|
3
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
table,
|
|
7
|
+
getDatabases,
|
|
8
|
+
database,
|
|
9
|
+
databaseDropPreparationTargets,
|
|
10
|
+
dropDatabase,
|
|
11
|
+
type Table,
|
|
12
|
+
} from '../../resources/databases.ts';
|
|
13
|
+
import {
|
|
14
|
+
claimDatabaseDropPreparations,
|
|
15
|
+
releaseDatabaseDropPreparations,
|
|
16
|
+
} from '../../resources/databaseDropPreparation.ts';
|
|
4
17
|
import insertUpdateValidate from './bridgeUtility/insertUpdateValidate.js';
|
|
5
18
|
import SearchObject from '../SearchObject.ts';
|
|
6
19
|
import {
|
|
@@ -186,8 +199,31 @@ export class ResourceBridge extends BridgeMethods {
|
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
async dropSchema(dropSchemaObj) {
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
const preparationId = randomUUID();
|
|
203
|
+
const { rootPaths } = databaseDropPreparationTargets(dropSchemaObj.schema);
|
|
204
|
+
const completion = () => {
|
|
205
|
+
const message: any = new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_SCHEMA, dropSchemaObj.schema);
|
|
206
|
+
message.dropPreparationId = preparationId;
|
|
207
|
+
message.dropPreparationOwnerThreadId = threadId;
|
|
208
|
+
message.dropPreparationRootPaths = rootPaths;
|
|
209
|
+
return message;
|
|
210
|
+
};
|
|
211
|
+
const preparation: any = completion();
|
|
212
|
+
preparation.prepareDrop = true;
|
|
213
|
+
claimDatabaseDropPreparations(rootPaths, preparationId, threadId, dropSchemaObj.schema);
|
|
214
|
+
try {
|
|
215
|
+
await signalling.signalSchemaChangeToPeers(preparation);
|
|
216
|
+
await dropDatabase(dropSchemaObj.schema, rootPaths);
|
|
217
|
+
} finally {
|
|
218
|
+
// The first round reaches current peers before local release; the second catches a worker whose
|
|
219
|
+
// startup snapshot inherited the fence. Cleanup-proven job workers are already exiting.
|
|
220
|
+
await signalling.signalSchemaChange(completion(), {
|
|
221
|
+
peersFirst: true,
|
|
222
|
+
includeJobWorkers: 'active',
|
|
223
|
+
peerRounds: 2,
|
|
224
|
+
});
|
|
225
|
+
releaseDatabaseDropPreparations(rootPaths, preparationId);
|
|
226
|
+
}
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
async updateRecords(updateObj) {
|