@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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# resources/models/ — model facade, backends, decisions
|
|
2
|
+
|
|
3
|
+
Notes on invariants the models layer relies on that the code cannot state itself. User-facing behavior is documented in HarperFast/documentation.
|
|
4
|
+
|
|
5
|
+
## Decision outputs are validated at the facade
|
|
6
|
+
|
|
7
|
+
A `Decision` returned by `models.decide` names a value from the schema's allowed set, and its distribution has exactly one entry per allowed value, with finite probabilities in [0, 1] that sum to one (within 1e-3), sorted descending with ties in schema order unless the backend chose one of the tied values, which then leads. `normalizeDecision` (`decision.ts`) enforces this on every backend output, so no backend's arithmetic is trusted; a violation is a `DecisionContractError`, which `Models.decide` treats like any backend error (recorded against that backend, next candidate tried). A malformed schema or state rejects before routing and writes no analytics row, because nothing was called. Object schemas are marginal-only: `value` is assembled from each field's argmax and may be a combination no single sample produced. Enforced by `unitTests/resources/models/decision.test.js` and `decide.test.js`.
|
|
8
|
+
|
|
9
|
+
## The generative adapter reports no usage of its own
|
|
10
|
+
|
|
11
|
+
Every sample the adapter (`generativeDecision.ts`) takes is a `models.generate` call with its own `hdb_model_calls` row and token metrics. The adapter therefore returns no `usage` on its `ModelCallResult`; reporting the sum again would count each sample twice in `SUM(prompt_tokens)` and in `model-*-tokens`, the same double-billing the `toolMode: 'auto'` outer call avoids by writing no row. Samples run under one `AbortController`: the first failure aborts the rest, and the worker pool is awaited to settlement before the error is thrown, so no sample rejects unobserved and the facade never falls back to another candidate while calls are still in flight. Enforced by `unitTests/resources/models/generativeDecision.test.js`.
|
|
12
|
+
|
|
13
|
+
## Required capabilities are checked on the candidate about to be invoked
|
|
14
|
+
|
|
15
|
+
The router's candidate list is advisory: the default router filters by `requires`, but a custom router installed with `registerRouter` may return any backend. `assertCapabilities` (`Models.ts`) therefore re-checks every required capability against `capabilities()` immediately before each candidate is invoked, in every facade method, fallbacks included, and for a stream both before the iterable is returned and again on its first `next()`; a miss is a `ModelCapabilityError` recorded as `capability_unsupported`, and the next candidate is tried, except by a stream, which only ever uses its first candidate. Candidates are backend instances captured when the call was resolved, so the check sees a backend's own `capabilities()` at invocation time, not a registry entry a reload has since replaced; the built-in providers return one frozen object, so the check allocates nothing. The generative decision backend relies on it: by default it passes `requires: ['structuredOutput']` on every sample, so a generative candidate whose integration does not send the response schema as a decoding constraint (Anthropic and Bedrock today) is refused before a completion is billed, whatever router produced it, unless the entry sets `requireStructuredOutput: false`. Enforced by the custom-router cases in `unitTests/resources/models/Models.test.js`; the bootstrap cases in `generativeDecision.test.js` cover the default router's filtering of the same requirement.
|
|
16
|
+
|
|
17
|
+
## Likelihood scoring is a leaf-level hook with voting as the fallback
|
|
18
|
+
|
|
19
|
+
A generative backend may implement `scoreChoices` (#2838): one finite, unnormalized log-likelihood per choice, in order, for a whole call, or a `ChoiceScoringUnsupportedError` for a call it cannot score. A decline is weaker news than a failure, and the two are kept apart at every layer. The facade's internal `models.scoreChoices` validates the vector's shape before it records a success, so a malformed result is one failed attempt against that backend and the next candidate is tried; when every candidate fails it surfaces the first failure, and the primary's decline only when every candidate declined, so a decline never hides a broken fallback. A declined call is recorded as `scoring_unsupported` with the usage the error carries, because a completion that came back without log-probabilities was still billed: those tokens land on that attempt's failure row and in the token metric while the call-count metric stays a count of successes. They are billed once: no other error's `usage` is read, no other method's failure row carries usage (the `decide` row stays usage-free when a decline propagates under `score`), and the adapter aborts sibling calls with a fresh reason rather than the decline itself, so a call cut short is recorded as `aborted` and not as another copy of the decline. The adapter scores one leaf per call (each property of an object schema separately), normalizes by softmax, and never mixes paths: in `auto` mode a decline, or a `ModelCapabilityError`, discards any leaf already scored and votes the whole decision under the same budget, so a distribution is wholly scored or wholly voted; any other error fails the decision. `auto` asks the router synchronously before deciding, so a generative backend whose capabilities say no costs no attempt, no exception and no analytics row. The same probe reads each candidate's `maxScoredChoices`: when no scoring candidate can take the schema's largest leaf, `auto` votes without a scoring attempt, and the facade treats a call above a candidate's limit as a decline it records without invoking that backend, so sibling leaves are never scored and then discarded. A candidate that advertises scoring without implementing it still counts as able, so its contract failure is reported rather than hidden by a vote. `maxScoredChoices` is a limit, not a `Capability`: it cannot be required. The OpenAI backend labels the choices `A`..`T` on one line each (a value that cannot sit on one line as itself is JSON-quoted, so it cannot pose as a further option and values that differ only in whitespace stay distinct), asks for one token with `top_logprobs: 20`, combines spellings of a label by log-sum-exp, and places a label with no listed spelling only when the mass the list leaves over is smaller than the leading observed label's, so a wholly unlisted label can never be the chosen value; otherwise the call is declined rather than guessed. An unlisted label then stands at a floor no higher than the smallest listed alternative or the leftover, finite even when the list leaves nothing over. Every placed score is still an estimate: an observed label's own unlisted spellings can add up to the leftover to it, so two observed labels closer than that may be ordered wrongly, and a response with no label at all is declined rather than reported as uniform. A malformed alternative is a failure, not a decline. A model that refuses `logprobs`, by a 400 (by `param`, or by a message that names it and says it is unsupported) or by an answer with no log-probability field at all, has scoring switched off until reload and `capabilities()` says so for the configured model, so later decisions vote without an attempt; a completion with no content token or no alternatives, and an answer that is simply not a label, are declined for that call only, and any other HTTP failure stays an error so an outage does not silently turn into voting. Probabilities from scoring are still `calibrated: false`. Enforced by `unitTests/resources/models/generativeDecision.test.js`, `scoreChoices.test.js` and `unitTests/components/openai/index.test.js`.
|
|
20
|
+
|
|
21
|
+
## `@decide` and `@embed` share one trigger rule
|
|
22
|
+
|
|
23
|
+
Both directives derive an attribute on the originating node before the record is staged, in `_writeUpdate` and in the cache-from-source write. `writeHookApplies` and `sourceState` (`embedHook.ts`) are the only trigger logic: a replication receive, an `x-replicate-from: none` write or an audit replay never re-derives, a payload without the source leaves the derived attributes alone, a null source clears them, and a CRDT op is skipped. `@decide` writes the chosen value and, when named, its probability from one `models.decide` call, and a failure fails the write, so the two are never from different decisions and never half-written; a missing decider or an override without the promised probability fails the write too. `runWriteJobs` aborts the siblings of a failed hook and settles them before the failure is reported, so a rejected write neither waits on a hook that honors the signal nor keeps being mutated by one; the built-in hooks forward the signal to their model calls. `assertDerivedFieldOwnership` gives every derived field one writer, refuses a directive whose source is another directive's output, because the hooks run concurrently within a write, holds a decided field and its confidence to what the loader accepts, and refuses an `Object.prototype` key as any of those fields, because `sourceState` tests presence with `in`. The loader runs it, and `declareTable` runs it on the complete list (a peer's fields merged, an omitted primary key inherited) before that list replaces the live one or reaches the catalog, so a programmatic `table({ attributes })` declaration is held to the same rules and a refused declaration changes nothing. `Table.updatedAttributes` runs it too but only logs a violation, because it also runs when a descriptor an earlier build accepted is loaded from the catalog, and a table must keep loading. The hooks receive the request's signal, so a client that disconnects cancels the model calls as it did before the hooks were combined. A `decide` descriptor change persists without a reindex: a stored decision does not depend on the model, unlike an embedding, whose `version` carries the model. Enforced by `unitTests/resources/models/decideHook.test.js`, `decideDirective.test.js`, `decideWrite.test.js` and `integrationTests/server/decide-directive.test.ts`.
|
|
24
|
+
|
|
25
|
+
## Built-ins serve one kind
|
|
26
|
+
|
|
27
|
+
The provider factories (`ollama`, `openai`, `anthropic`, `bedrock`) treat every non-embedding kind as generative, so a `models.decision` entry naming one would register a backend with no `decide`. `builtinServesKind` (`bootstrap.ts`) refuses that entry at boot and reload, and `wrongKindEntrySchema` (`validation/configValidator.ts`) refuses it at validation, in both directions: `backend: generative` is the only built-in for `decision` and is refused under `embedding` and `generative`. Module backends are exempt because their factory receives `kind`.
|
|
28
|
+
|
|
29
|
+
## A recorded decision is committed before its id is returned, and its facts are separate rows
|
|
30
|
+
|
|
31
|
+
Recording is opt-in: `Models.decide` stores nothing beyond the per-call analytics row unless the call passes `persist: true`, so by default it costs what `embed` and `generate` cost and works on any node; only callers that will report outcomes pay for a durable record. The option types carry that promise: `RecordedDecideOpts` (`persist: true`) returns a `RecordedDecision` whose `id` is present, and any other options object returns a `Decision` whose `id` is optional. With `persist: true`, `Decision.id` names a row in `hdb_model_decisions` that `Models.decide` commits in a transaction of its own (`decisionStore.ts`: `transaction()` with a fresh context object, so the write never joins an ambient application transaction and survives that transaction's abort) before the decision is returned. The row is written once and never rewritten; each recorded fact (`truth` or `action`, per field for object schemas) is its own `hdb_model_outcomes` row keyed `<id>/<fact>[/<field>]`, so two facts recorded on two nodes cannot overwrite each other and a report never re-expires the prediction: facts copy the decision's `expiresAt`, and a report whose state equals the stored fact writes nothing. The id is a UUID because `getNextMonotonicTime()` is monotonic per process, not unique across nodes, and both tables replicate: they are declared with `replicate: true`, as the scheduler state table is, so a scoped `replication.databases` cannot exclude them, with `audit: true` as the feed, and neither is in `NON_REPLICATING_SYSTEM_TABLES`. With `persist: true`, a commit failure after the backend succeeded throws `DecisionPersistenceError` outside the candidate loop, so a storage fault never bills a second model call, and a read-only node rejects before routing. Both tables are provisioned with their primary key by `json/systemSchema.json` and the `5.3.0` directive, and `declareDecisionTablesAtBoot` (called from `componentLoader` after `bootstrapModels`) layers the attributes and the indexed `expiresAt` TTL on every writable node, so a peer that never decides still evicts. Enforced by `unitTests/resources/models/decisionStore.test.js` and `decideOutcome.test.js`.
|
|
32
|
+
|
|
33
|
+
## No-match is a separate score, never a value
|
|
34
|
+
|
|
35
|
+
A leaf opts in with `noMatch: true` (#2846, decided on #2843). The backend then returns a score in `[0, 1]` that the input matches none of the allowed values, beside the unchanged forced-choice `distribution`; the two are not a joint distribution, so the all-votes-no-match case still has a closest allowed value. No sentinel ever enters an enum, which is why boolean and integer leaves can opt in. An opted-in call requires the `noMatch` capability (and `calibratedNoMatch` when it requires `calibrated`), checked per candidate like every other requirement, and reports `calibrated: true` only when both scores are calibrated. A score on a leaf that did not opt in is dropped, so a schema without the flag sees exactly the pre-#2846 output. The generative adapter asks an opted-in leaf for `{ value, noMatch }` when voting and adds one distinct "none" choice when scoring, renormalizing the allowed values without it. Enforced by `unitTests/resources/models/noMatch.test.js`.
|
|
36
|
+
|
|
37
|
+
## A `@decide` decision is recorded only when the record can reach it
|
|
38
|
+
|
|
39
|
+
The built-in decider passes `persist: true` only when the directive names a `decision` attribute, so a directive nobody can report outcomes for writes no `hdb_model_decisions` row (#2852). With one, the decider records the decision and the hook stores `Decision.id` beside the value and confidence; a null source clears all three. The id is provenance an outcome report trusts, so `buildDecideBefore` rejects a write that carries it unless that same write derives or clears it, or arrives by replication or replay; `x-replicate-from: none` and a CRDT operation on the source do not qualify, since any request can send them. The association is bounded, not atomic: the decision commits in its own transaction before the record, so an aborted write, a later source write, or a delete leaves an unreferenced row that expires with the retention. On a read-only node no write can commit, so `Table` skips the model hooks and serves a source-cache fill without staging it (`noCacheStore`), for every table. Enforced by `decideHook.test.js`, `decideWrite.test.js`, `decideOutcome.test.js` and `integrationTests/server/decide-directive.test.ts`.
|
|
@@ -9,12 +9,44 @@ import {
|
|
|
9
9
|
import { getRouter, registerRouter as registerRouterImpl } from './routing.ts';
|
|
10
10
|
import { getModelCallAnalyticsWriter, type ModelCallAnalyticsWriter, type ModelCallRecord } from './analyticsTable.ts';
|
|
11
11
|
import { recordAction } from '../analytics/write.ts';
|
|
12
|
-
import { ServerError } from '../../utility/errors/hdbError.ts';
|
|
12
|
+
import { ClientError, ServerError } from '../../utility/errors/hdbError.ts';
|
|
13
13
|
import { runAgentLoop, runAgentLoopStream } from './agentLoop.ts';
|
|
14
|
+
import { assignFiniteTokenCount, ChoiceScoringUnsupportedError } from './backendHelpers.ts';
|
|
15
|
+
import {
|
|
16
|
+
DecisionContractError,
|
|
17
|
+
DecisionInputError,
|
|
18
|
+
hashSchema,
|
|
19
|
+
hashText,
|
|
20
|
+
normalizeDecision,
|
|
21
|
+
wantsNoMatch,
|
|
22
|
+
scoringSchema,
|
|
23
|
+
snapshotSchema,
|
|
24
|
+
stateToText,
|
|
25
|
+
validateDecisionSchema,
|
|
26
|
+
} from './decision.ts';
|
|
27
|
+
import {
|
|
28
|
+
DECISION_RETENTION_MS,
|
|
29
|
+
DecisionPersistenceError,
|
|
30
|
+
type DecisionRow,
|
|
31
|
+
type DecisionStore,
|
|
32
|
+
getDecisionStore,
|
|
33
|
+
getModelsConfigHash,
|
|
34
|
+
newDecisionId,
|
|
35
|
+
} from './decisionStore.ts';
|
|
14
36
|
import type {
|
|
15
37
|
AccountingContext,
|
|
38
|
+
ModelCapabilities,
|
|
16
39
|
BackendOpts,
|
|
17
40
|
Capability,
|
|
41
|
+
ChoiceScores,
|
|
42
|
+
DecideInput,
|
|
43
|
+
DecideOpts,
|
|
44
|
+
RecordedDecideOpts,
|
|
45
|
+
RecordedDecision,
|
|
46
|
+
Decision,
|
|
47
|
+
DecisionOutput,
|
|
48
|
+
DecisionRecord,
|
|
49
|
+
DecisionSchema,
|
|
18
50
|
DefineBackendSpec,
|
|
19
51
|
EmbedOpts,
|
|
20
52
|
GenerateChunk,
|
|
@@ -22,13 +54,17 @@ import type {
|
|
|
22
54
|
GenerateOpts,
|
|
23
55
|
GenerateResult,
|
|
24
56
|
ModelBackend,
|
|
57
|
+
ModelKind,
|
|
25
58
|
ModelRouter,
|
|
26
59
|
ModelCallResult,
|
|
27
60
|
Models as ModelsContract,
|
|
61
|
+
ScoreChoicesOpts,
|
|
62
|
+
OutcomeReport,
|
|
28
63
|
TokenUsage,
|
|
29
64
|
} from './types.ts';
|
|
30
65
|
|
|
31
66
|
type CallMethod = ModelCallRecord['method'];
|
|
67
|
+
type CallIdentity = { hash: string; scoring: DecisionSchema; configHash?: string };
|
|
32
68
|
type MetricEmitter = (value: number, metric: string, path?: string) => void;
|
|
33
69
|
|
|
34
70
|
/**
|
|
@@ -50,14 +86,17 @@ type MetricEmitter = (value: number, metric: string, path?: string) => void;
|
|
|
50
86
|
export class Models implements ModelsContract {
|
|
51
87
|
#analyticsWriter: ModelCallAnalyticsWriter;
|
|
52
88
|
#emit: MetricEmitter;
|
|
89
|
+
#decisionStore: DecisionStore;
|
|
53
90
|
|
|
54
91
|
constructor(
|
|
55
92
|
analyticsWriter: ModelCallAnalyticsWriter = getModelCallAnalyticsWriter(),
|
|
56
93
|
// DI'd for unit tests; production wires up the module-scope `recordAction`.
|
|
57
|
-
metricEmitter: MetricEmitter = recordAction
|
|
94
|
+
metricEmitter: MetricEmitter = recordAction,
|
|
95
|
+
decisionStore: DecisionStore = getDecisionStore()
|
|
58
96
|
) {
|
|
59
97
|
this.#analyticsWriter = analyticsWriter;
|
|
60
98
|
this.#emit = metricEmitter;
|
|
99
|
+
this.#decisionStore = decisionStore;
|
|
61
100
|
}
|
|
62
101
|
|
|
63
102
|
/**
|
|
@@ -67,7 +106,7 @@ export class Models implements ModelsContract {
|
|
|
67
106
|
* `models.defineBackend` to build the backend from a few methods. Namespaced
|
|
68
107
|
* under `models` (`scope.models.registerBackend(...)`), not a generic global. See #1325.
|
|
69
108
|
*/
|
|
70
|
-
registerBackend(kind:
|
|
109
|
+
registerBackend(kind: ModelKind, id: string, backend: ModelBackend): void {
|
|
71
110
|
registerBackendImpl(kind, id, backend);
|
|
72
111
|
}
|
|
73
112
|
|
|
@@ -125,6 +164,7 @@ export class Models implements ModelsContract {
|
|
|
125
164
|
signal?.throwIfAborted();
|
|
126
165
|
const attemptStart = performance.now();
|
|
127
166
|
try {
|
|
167
|
+
assertCapabilities(backend, resolved.requires);
|
|
128
168
|
const backendOpts = toBackendOpts(opts, signal, accounting);
|
|
129
169
|
const result = await backend.embed!(input, backendOpts);
|
|
130
170
|
// Throw on `pending` BEFORE recording success — otherwise we'd write a
|
|
@@ -179,6 +219,7 @@ export class Models implements ModelsContract {
|
|
|
179
219
|
signal?.throwIfAborted();
|
|
180
220
|
const attemptStart = performance.now();
|
|
181
221
|
try {
|
|
222
|
+
assertCapabilities(backend, resolved.requires);
|
|
182
223
|
const backendOpts = toBackendOpts(opts, signal, accounting);
|
|
183
224
|
const result = await backend.generate!(input, backendOpts);
|
|
184
225
|
if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name);
|
|
@@ -223,8 +264,16 @@ export class Models implements ModelsContract {
|
|
|
223
264
|
}
|
|
224
265
|
// First candidate only — mid-stream fallback would mean replaying already-yielded chunks.
|
|
225
266
|
const backend = resolved.candidates[0];
|
|
267
|
+
// Checked here, before the iterable exists, so an incapable first candidate from a custom router
|
|
268
|
+
// is a 400 rather than an error frame inside a 200; #wrapStream checks again on the first next().
|
|
269
|
+
try {
|
|
270
|
+
assertCapabilities(backend, resolved.requires);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
this.#recordFailure(backend, 'generateStream', opts.model, accounting, opts, startedAt, err);
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
226
275
|
const backendOpts = toBackendOpts(opts, signal, accounting);
|
|
227
|
-
return this.#wrapStream(backend, input, backendOpts, opts, accounting, startedAt);
|
|
276
|
+
return this.#wrapStream(backend, input, backendOpts, opts, accounting, startedAt, resolved.requires);
|
|
228
277
|
}
|
|
229
278
|
|
|
230
279
|
async *#wrapStream(
|
|
@@ -233,11 +282,13 @@ export class Models implements ModelsContract {
|
|
|
233
282
|
backendOpts: BackendOpts<GenerateOpts>,
|
|
234
283
|
opts: GenerateOpts,
|
|
235
284
|
accounting: AccountingContext,
|
|
236
|
-
startedAt: number
|
|
285
|
+
startedAt: number,
|
|
286
|
+
requires: Capability[]
|
|
237
287
|
): AsyncIterable<GenerateChunk> {
|
|
238
288
|
let caught: unknown;
|
|
239
289
|
let completed = false;
|
|
240
290
|
try {
|
|
291
|
+
assertCapabilities(backend, requires);
|
|
241
292
|
for await (const chunk of backend.generateStream!(input, backendOpts)) {
|
|
242
293
|
yield chunk;
|
|
243
294
|
}
|
|
@@ -259,6 +310,245 @@ export class Models implements ModelsContract {
|
|
|
259
310
|
}
|
|
260
311
|
}
|
|
261
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Choose from the closed set `schema` defines, with the distribution over it. A malformed schema or
|
|
315
|
+
* state rejects before routing, with no analytics row: nothing was called. With `persist: true` the
|
|
316
|
+
* record is committed before the decision is returned: a read-only node rejects before routing, and a
|
|
317
|
+
* commit failure rejects without another candidate, so a storage fault never bills a second model
|
|
318
|
+
* call. Without it nothing is stored and no `id` is returned, so any node can decide.
|
|
319
|
+
*/
|
|
320
|
+
decide<T = unknown>(
|
|
321
|
+
state: DecideInput,
|
|
322
|
+
schema: DecisionSchema,
|
|
323
|
+
opts: RecordedDecideOpts
|
|
324
|
+
): Promise<RecordedDecision<T>>;
|
|
325
|
+
decide<T = unknown>(state: DecideInput, schema: DecisionSchema, opts?: DecideOpts): Promise<Decision<T>>;
|
|
326
|
+
async decide<T = unknown>(state: DecideInput, schema: DecisionSchema, opts: DecideOpts = {}): Promise<Decision<T>> {
|
|
327
|
+
validateDecisionSchema(schema);
|
|
328
|
+
stateToText(state);
|
|
329
|
+
if (opts.persist !== undefined && typeof opts.persist !== 'boolean')
|
|
330
|
+
throw new DecisionInputError('persist must be a boolean');
|
|
331
|
+
const persist = opts.persist === true;
|
|
332
|
+
if (persist) this.#decisionStore.assertWritable('Decisions');
|
|
333
|
+
// the call and its record share one snapshot of the schema and options; nothing the caller mutates afterwards reaches either
|
|
334
|
+
const call = snapshotSchema(schema);
|
|
335
|
+
if (opts.instructions !== undefined && typeof opts.instructions !== 'string')
|
|
336
|
+
throw new DecisionInputError('instructions must be a string');
|
|
337
|
+
const instructions = opts.instructions || undefined;
|
|
338
|
+
const callOpts = { ...opts, instructions, requires: opts.requires ? [...opts.requires] : undefined } as DecideOpts;
|
|
339
|
+
if (opts.persist !== undefined) delete callOpts.persist;
|
|
340
|
+
const identity: CallIdentity | undefined = persist
|
|
341
|
+
? { hash: hashSchema(call), scoring: scoringSchema(call), configHash: getModelsConfigHash() }
|
|
342
|
+
: undefined;
|
|
343
|
+
const { accounting, signal } = resolveCallContext(callOpts.signal);
|
|
344
|
+
const startedAt = performance.now();
|
|
345
|
+
const requires = buildRequires('decide', callOpts.requires, false);
|
|
346
|
+
if (wantsNoMatch(call)) {
|
|
347
|
+
if (!requires.includes('noMatch')) requires.push('noMatch');
|
|
348
|
+
if (requires.includes('calibrated') && !requires.includes('calibratedNoMatch'))
|
|
349
|
+
requires.push('calibratedNoMatch');
|
|
350
|
+
}
|
|
351
|
+
const resolved = resolveCandidates('decision', callOpts.model, requires);
|
|
352
|
+
if ('error' in resolved) {
|
|
353
|
+
this.#recordFailure(resolved.backend, 'decide', callOpts.model, accounting, undefined, startedAt, resolved.error);
|
|
354
|
+
throw resolved.error;
|
|
355
|
+
}
|
|
356
|
+
let firstError: unknown = undefined;
|
|
357
|
+
let hasError = false;
|
|
358
|
+
for (const backend of resolved.candidates) {
|
|
359
|
+
signal?.throwIfAborted();
|
|
360
|
+
const attemptStart = performance.now();
|
|
361
|
+
let result: ModelCallResult<DecisionOutput<unknown>>;
|
|
362
|
+
let decision: Omit<Decision<T>, 'id' | 'usage'>;
|
|
363
|
+
try {
|
|
364
|
+
assertCapabilities(backend, resolved.requires);
|
|
365
|
+
const backendOpts = toBackendOpts(callOpts, signal, accounting);
|
|
366
|
+
result = await backend.decide!(state, call, backendOpts);
|
|
367
|
+
if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name);
|
|
368
|
+
const caps = backend.capabilities();
|
|
369
|
+
decision = normalizeDecision<T>(
|
|
370
|
+
call,
|
|
371
|
+
result.output,
|
|
372
|
+
backend.name,
|
|
373
|
+
caps?.calibrated === true,
|
|
374
|
+
caps?.calibratedNoMatch === true
|
|
375
|
+
);
|
|
376
|
+
// A backend may report a single call as uncalibrated; the caller's requirement still holds.
|
|
377
|
+
if (!decision.calibrated && callOpts.requires?.includes('calibrated'))
|
|
378
|
+
throw new DecisionContractError(
|
|
379
|
+
backend.name,
|
|
380
|
+
"probabilities are not calibrated, but 'calibrated' was required"
|
|
381
|
+
);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
this.#recordFailure(backend, 'decide', callOpts.model, accounting, undefined, attemptStart, err);
|
|
384
|
+
if (!hasError) {
|
|
385
|
+
firstError = err;
|
|
386
|
+
hasError = true;
|
|
387
|
+
}
|
|
388
|
+
if (signal?.aborted) throw err;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const callId = this.#record(backend, 'decide', callOpts.model, accounting, undefined, result, attemptStart);
|
|
392
|
+
if (!identity) return result.usage ? { ...decision, usage: result.usage } : decision;
|
|
393
|
+
const id = await this.#persistDecision(
|
|
394
|
+
callId,
|
|
395
|
+
backend,
|
|
396
|
+
callOpts.model,
|
|
397
|
+
instructions,
|
|
398
|
+
accounting,
|
|
399
|
+
result.output,
|
|
400
|
+
identity,
|
|
401
|
+
decision
|
|
402
|
+
);
|
|
403
|
+
return result.usage ? { id, ...decision, usage: result.usage } : { id, ...decision };
|
|
404
|
+
}
|
|
405
|
+
throw firstError;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Score `choices` as answers to `input` with a generative backend that implements
|
|
410
|
+
* `scoreChoices` (#2838): the decision adapter's likelihood path. Routes `generative` with
|
|
411
|
+
* `scoreChoices` required, tries candidates like `embed`, and records one `scoreChoices` row
|
|
412
|
+
* per attempt; a `ChoiceScoringUnsupportedError` is recorded as `scoring_unsupported` with the
|
|
413
|
+
* usage it consumed. Internal, like `embedWithUsage`: not part of the stable models API.
|
|
414
|
+
*/
|
|
415
|
+
async scoreChoices(
|
|
416
|
+
input: GenerateInput,
|
|
417
|
+
choices: readonly string[],
|
|
418
|
+
opts: ScoreChoicesOpts = {}
|
|
419
|
+
): Promise<ChoiceScores & { usage?: TokenUsage }> {
|
|
420
|
+
const { accounting, signal } = resolveCallContext(opts.signal);
|
|
421
|
+
const startedAt = performance.now();
|
|
422
|
+
const resolved = resolveCandidates('generative', opts.model, buildRequires('scoreChoices', opts.requires, false));
|
|
423
|
+
if ('error' in resolved) {
|
|
424
|
+
this.#recordFailure(
|
|
425
|
+
resolved.backend,
|
|
426
|
+
'scoreChoices',
|
|
427
|
+
opts.model,
|
|
428
|
+
accounting,
|
|
429
|
+
undefined,
|
|
430
|
+
startedAt,
|
|
431
|
+
resolved.error
|
|
432
|
+
);
|
|
433
|
+
throw resolved.error;
|
|
434
|
+
}
|
|
435
|
+
let firstError: unknown = undefined;
|
|
436
|
+
let hasError = false;
|
|
437
|
+
let firstFailure: unknown = undefined;
|
|
438
|
+
let hasFailure = false;
|
|
439
|
+
for (const backend of resolved.candidates) {
|
|
440
|
+
signal?.throwIfAborted();
|
|
441
|
+
const attemptStart = performance.now();
|
|
442
|
+
try {
|
|
443
|
+
const caps = assertCapabilities(backend, resolved.requires);
|
|
444
|
+
// A router may hand back a backend whose capabilities claim more than it implements;
|
|
445
|
+
// that is this backend's contract failure, recorded against it, not a crash.
|
|
446
|
+
if (typeof backend.scoreChoices !== 'function')
|
|
447
|
+
throw new ServerError(`Backend '${backend.name}' advertises 'scoreChoices' but does not implement it`);
|
|
448
|
+
const limit = scoringLimit(caps);
|
|
449
|
+
if (limit !== undefined && choices.length > limit)
|
|
450
|
+
throw new ChoiceScoringUnsupportedError(
|
|
451
|
+
`Backend '${backend.name}' scores at most ${limit} choices per call; got ${choices.length}`
|
|
452
|
+
);
|
|
453
|
+
const result = await backend.scoreChoices(input, choices, toBackendOpts(opts, signal, accounting));
|
|
454
|
+
if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name);
|
|
455
|
+
const logLikelihoods = result.output?.logLikelihoods;
|
|
456
|
+
if (
|
|
457
|
+
!Array.isArray(logLikelihoods) ||
|
|
458
|
+
logLikelihoods.length !== choices.length ||
|
|
459
|
+
!logLikelihoods.every((x) => typeof x === 'number' && Number.isFinite(x))
|
|
460
|
+
)
|
|
461
|
+
throw new ServerError(
|
|
462
|
+
`Backend '${backend.name}' did not return one finite log-likelihood per choice (${choices.length})`
|
|
463
|
+
);
|
|
464
|
+
this.#record(backend, 'scoreChoices', opts.model, accounting, undefined, result, attemptStart);
|
|
465
|
+
return result.usage ? { logLikelihoods, usage: result.usage } : { logLikelihoods };
|
|
466
|
+
} catch (err) {
|
|
467
|
+
// Only this attempt's own decline bills its tokens: the error may travel on as an abort
|
|
468
|
+
// reason or through the decision adapter, and those rows must not count it again.
|
|
469
|
+
this.#recordFailure(
|
|
470
|
+
backend,
|
|
471
|
+
'scoreChoices',
|
|
472
|
+
opts.model,
|
|
473
|
+
accounting,
|
|
474
|
+
undefined,
|
|
475
|
+
attemptStart,
|
|
476
|
+
err,
|
|
477
|
+
usageFromError(err)
|
|
478
|
+
);
|
|
479
|
+
if (!hasError) {
|
|
480
|
+
firstError = err;
|
|
481
|
+
hasError = true;
|
|
482
|
+
}
|
|
483
|
+
if (!hasFailure && !isChoiceScoringUnsupported(err)) {
|
|
484
|
+
firstFailure = err;
|
|
485
|
+
hasFailure = true;
|
|
486
|
+
}
|
|
487
|
+
if (signal?.aborted) throw err;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
throw hasFailure ? firstFailure : firstError;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async getDecision<T = unknown>(id: string): Promise<DecisionRecord<T> | undefined> {
|
|
494
|
+
checkDecisionId(id);
|
|
495
|
+
return (await this.#decisionStore.get(id, resolveCallContext().accounting.tenantId)) as
|
|
496
|
+
DecisionRecord<T> | undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async recordOutcome<T = unknown>(id: string, outcome: OutcomeReport): Promise<DecisionRecord<T>> {
|
|
500
|
+
checkDecisionId(id);
|
|
501
|
+
return (await this.#decisionStore.recordOutcome(
|
|
502
|
+
id,
|
|
503
|
+
outcome,
|
|
504
|
+
resolveCallContext().accounting.tenantId
|
|
505
|
+
)) as DecisionRecord<T>;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async #persistDecision<T>(
|
|
509
|
+
callId: number,
|
|
510
|
+
backend: ModelBackend,
|
|
511
|
+
model: string | undefined,
|
|
512
|
+
instructions: string | undefined,
|
|
513
|
+
accounting: AccountingContext,
|
|
514
|
+
output: DecisionOutput<unknown>,
|
|
515
|
+
identity: CallIdentity,
|
|
516
|
+
decision: Omit<Decision<T>, 'id' | 'usage'>
|
|
517
|
+
): Promise<string> {
|
|
518
|
+
const id = newDecisionId();
|
|
519
|
+
const at = Date.now();
|
|
520
|
+
const row: DecisionRow = {
|
|
521
|
+
id,
|
|
522
|
+
callId,
|
|
523
|
+
at,
|
|
524
|
+
expiresAt: at + DECISION_RETENTION_MS,
|
|
525
|
+
tenant: accounting.tenantId,
|
|
526
|
+
app: accounting.app,
|
|
527
|
+
backend: backend.name,
|
|
528
|
+
model: model ?? 'default',
|
|
529
|
+
signature: typeof output.signature === 'string' ? output.signature : undefined,
|
|
530
|
+
configHash: identity.configHash,
|
|
531
|
+
instructionsHash: instructions ? hashText(instructions) : undefined,
|
|
532
|
+
schema: identity.scoring,
|
|
533
|
+
schemaHash: identity.hash,
|
|
534
|
+
value: decision.value,
|
|
535
|
+
probability: decision.probability,
|
|
536
|
+
distribution: decision.distribution,
|
|
537
|
+
fields: decision.fields,
|
|
538
|
+
calibrated: decision.calibrated,
|
|
539
|
+
};
|
|
540
|
+
if (decision.noMatch !== undefined) row.noMatch = decision.noMatch;
|
|
541
|
+
try {
|
|
542
|
+
await this.#decisionStore.persist(row);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
// the fault stays on `cause`: its message can name a data path and this error can reach an HTTP body
|
|
545
|
+
const error = new DecisionPersistenceError('Decision could not be recorded');
|
|
546
|
+
(error as Error & { cause?: unknown }).cause = err;
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
return id;
|
|
550
|
+
}
|
|
551
|
+
|
|
262
552
|
#record(
|
|
263
553
|
backend: ModelBackend,
|
|
264
554
|
method: CallMethod,
|
|
@@ -267,9 +557,11 @@ export class Models implements ModelsContract {
|
|
|
267
557
|
opts: GenerateOpts | undefined,
|
|
268
558
|
result: ModelCallResult<unknown> | undefined,
|
|
269
559
|
startedAt: number
|
|
270
|
-
):
|
|
560
|
+
): number {
|
|
271
561
|
const usage = result?.status === 'completed' ? result.usage : undefined;
|
|
272
|
-
this.#analyticsWriter.write(
|
|
562
|
+
const id = this.#analyticsWriter.write(
|
|
563
|
+
buildRecord(backend, method, model, accounting, opts, usage, startedAt, true)
|
|
564
|
+
);
|
|
273
565
|
// Also emit aggregate analytics into hdb_raw_analytics so model usage rolls up
|
|
274
566
|
// into the same per-period analytics that license enforcement and admin
|
|
275
567
|
// dashboards consume — mirrors the `db-read` pattern in Table.ts. The detailed
|
|
@@ -277,10 +569,14 @@ export class Models implements ModelsContract {
|
|
|
277
569
|
// Path is the backend name (analogous to tableName for db-read) so dashboards
|
|
278
570
|
// can break usage down by backend.
|
|
279
571
|
this.#emit(1, `model-${method}`, backend.name);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
572
|
+
this.#emitTokens(method, backend.name, usage);
|
|
573
|
+
return id;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#emitTokens(method: CallMethod, backendName: string, usage: TokenUsage | undefined): void {
|
|
577
|
+
if (!usage) return;
|
|
578
|
+
const tokens = (usage.embeddingTokens ?? 0) + (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0);
|
|
579
|
+
if (tokens > 0) this.#emit(tokens, `model-${method}-tokens`, backendName);
|
|
284
580
|
}
|
|
285
581
|
|
|
286
582
|
#recordFailure(
|
|
@@ -290,16 +586,38 @@ export class Models implements ModelsContract {
|
|
|
290
586
|
accounting: AccountingContext,
|
|
291
587
|
opts: GenerateOpts | undefined,
|
|
292
588
|
startedAt: number,
|
|
293
|
-
errOrCode: unknown
|
|
589
|
+
errOrCode: unknown,
|
|
590
|
+
usage?: TokenUsage
|
|
294
591
|
): void {
|
|
295
592
|
const error_code = typeof errOrCode === 'string' ? errOrCode : classifyError(errOrCode);
|
|
296
593
|
this.#analyticsWriter.write({
|
|
297
|
-
...buildRecord(backend, method, model, accounting, opts,
|
|
594
|
+
...buildRecord(backend, method, model, accounting, opts, usage, startedAt, false),
|
|
298
595
|
error_code,
|
|
299
596
|
});
|
|
597
|
+
if (backend) this.#emitTokens(method, backend.name, usage);
|
|
300
598
|
}
|
|
301
599
|
}
|
|
302
600
|
|
|
601
|
+
function isChoiceScoringUnsupported(err: unknown): boolean {
|
|
602
|
+
return (err as { name?: string } | null)?.name === 'ChoiceScoringUnsupportedError';
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Tokens a declined scoring call consumed (`ChoiceScoringUnsupportedError.usage`), finite counts
|
|
607
|
+
* only: a completion that came back without log-probabilities was billed, so they land on that
|
|
608
|
+
* attempt's failure row and in the token metric while the call-count metric stays a count of
|
|
609
|
+
* successes. No other error's `usage` is read, and no other method's failure row carries usage.
|
|
610
|
+
*/
|
|
611
|
+
function usageFromError(err: unknown): TokenUsage | undefined {
|
|
612
|
+
if (!isChoiceScoringUnsupported(err)) return undefined;
|
|
613
|
+
const reported = (err as { usage?: unknown }).usage;
|
|
614
|
+
if (!reported || typeof reported !== 'object') return undefined;
|
|
615
|
+
const usage: TokenUsage = {};
|
|
616
|
+
for (const key of ['promptTokens', 'completionTokens', 'embeddingTokens'] as const)
|
|
617
|
+
assignFiniteTokenCount(usage, key, (reported as Record<string, unknown>)[key]);
|
|
618
|
+
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
303
621
|
function buildRecord(
|
|
304
622
|
backend: ModelBackend | undefined,
|
|
305
623
|
method: CallMethod,
|
|
@@ -350,8 +668,26 @@ function inputHasTools(input: GenerateInput): boolean {
|
|
|
350
668
|
return typeof input === 'object' && !Array.isArray(input) && Array.isArray(input.tools) && input.tools.length > 0;
|
|
351
669
|
}
|
|
352
670
|
|
|
353
|
-
type
|
|
354
|
-
|
|
671
|
+
type Resolution =
|
|
672
|
+
{ candidates: ModelBackend[]; requires: Capability[] } | { error: Error; backend: ModelBackend | undefined };
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The router's list is advisory: a custom router may return a backend that lacks a required
|
|
676
|
+
* capability, and the facade must not invoke it. Checked immediately before each candidate is
|
|
677
|
+
* invoked, fallbacks included.
|
|
678
|
+
*/
|
|
679
|
+
function assertCapabilities(backend: ModelBackend, requires: Capability[]): ModelCapabilities | undefined {
|
|
680
|
+
const caps = backend.capabilities();
|
|
681
|
+
for (const capability of requires) {
|
|
682
|
+
if (!caps?.[capability]) throw new ModelCapabilityError(backend.name, capability);
|
|
683
|
+
}
|
|
684
|
+
return caps;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function scoringLimit(caps: ModelCapabilities | undefined): number | undefined {
|
|
688
|
+
const limit = caps?.maxScoredChoices;
|
|
689
|
+
return Number.isSafeInteger(limit) && (limit as number) > 0 ? (limit as number) : undefined;
|
|
690
|
+
}
|
|
355
691
|
|
|
356
692
|
/**
|
|
357
693
|
* Resolve a call to its ordered candidate backends via the active router (#1326),
|
|
@@ -381,10 +717,10 @@ function toBackendOpts<TOpts extends { model?: string; signal?: AbortSignal }>(
|
|
|
381
717
|
return backendOpts;
|
|
382
718
|
}
|
|
383
719
|
|
|
384
|
-
function resolveCandidates(kind:
|
|
720
|
+
function resolveCandidates(kind: ModelKind, model: string | undefined, requires: Capability[]): Resolution {
|
|
385
721
|
const logicalName = model ?? 'default';
|
|
386
|
-
const candidates = getRouter().route({ kind, logicalName, requires });
|
|
387
|
-
if (candidates.length > 0) return { candidates };
|
|
722
|
+
const candidates = getRouter().route({ kind, logicalName, requires: [...requires] });
|
|
723
|
+
if (candidates.length > 0) return { candidates, requires };
|
|
388
724
|
const primary = getBackend(kind, logicalName);
|
|
389
725
|
if (!primary) return { error: new ModelBackendNotFoundError(kind, logicalName), backend: undefined };
|
|
390
726
|
// `caps?.[…]` guards a custom backend whose capabilities() returns nullish — a
|
|
@@ -398,6 +734,8 @@ function resolveCandidates(kind: ResolveKind, model: string | undefined, require
|
|
|
398
734
|
/** Capabilities a call requires: its base method, the caller's `requires`, and `tools` when the input declares them. */
|
|
399
735
|
function buildRequires(base: Capability, requires: Capability[] | undefined, includeTools: boolean): Capability[] {
|
|
400
736
|
const set = new Set<Capability>([base, ...(requires ?? [])]);
|
|
737
|
+
// A limit is not a capability; from untyped callers it would read as satisfied by any number.
|
|
738
|
+
set.delete('maxScoredChoices' as Capability);
|
|
401
739
|
if (includeTools) set.add('tools');
|
|
402
740
|
return [...set];
|
|
403
741
|
}
|
|
@@ -407,6 +745,7 @@ function classifyError(err: unknown): string {
|
|
|
407
745
|
const e = err as { name?: string; code?: string };
|
|
408
746
|
if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return 'aborted';
|
|
409
747
|
if (e.name === 'ModelCapabilityError') return 'capability_unsupported';
|
|
748
|
+
if (e.name === 'ChoiceScoringUnsupportedError') return 'scoring_unsupported';
|
|
410
749
|
if (e.name === 'ModelBackendNotFoundError') return 'backend_not_found';
|
|
411
750
|
if (e.name === 'ModelPendingNotSupportedError') return 'pending_unsupported';
|
|
412
751
|
}
|
|
@@ -444,3 +783,7 @@ _assignPackageExport('models', models);
|
|
|
444
783
|
// The backend-registration API is reachable as `models.registerBackend(...)` /
|
|
445
784
|
// `models.defineBackend(...)` — methods on the `models` singleton above, not generic
|
|
446
785
|
// free globals (#1534). Custom routers install via `models.registerRouter(...)` (#1326).
|
|
786
|
+
|
|
787
|
+
function checkDecisionId(id: unknown): asserts id is string {
|
|
788
|
+
if (typeof id !== 'string' || id.length === 0) throw new ClientError('A decision id must be a non-empty string', 400);
|
|
789
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { allowedValues, isObjectSchema } from './decision.ts';
|
|
1
2
|
import type {
|
|
2
3
|
BackendOpts,
|
|
4
|
+
ChoiceScores,
|
|
5
|
+
DecideInput,
|
|
6
|
+
DecideOpts,
|
|
7
|
+
DecisionLeaf,
|
|
8
|
+
DecisionOutcome,
|
|
9
|
+
DecisionOutput,
|
|
10
|
+
DecisionSchema,
|
|
3
11
|
EmbedOpts,
|
|
4
12
|
GenerateChunk,
|
|
5
13
|
GenerateInput,
|
|
@@ -8,6 +16,7 @@ import type {
|
|
|
8
16
|
ModelBackend,
|
|
9
17
|
ModelCallResult,
|
|
10
18
|
ModelCapabilities,
|
|
19
|
+
ScoreChoicesOpts,
|
|
11
20
|
} from './types.ts';
|
|
12
21
|
|
|
13
22
|
/**
|
|
@@ -21,7 +30,16 @@ export class TestBackend implements ModelBackend {
|
|
|
21
30
|
readonly name = 'test';
|
|
22
31
|
|
|
23
32
|
capabilities(): ModelCapabilities {
|
|
24
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
embed: true,
|
|
35
|
+
generate: true,
|
|
36
|
+
stream: true,
|
|
37
|
+
tools: false,
|
|
38
|
+
adapters: false,
|
|
39
|
+
decide: true,
|
|
40
|
+
calibrated: false,
|
|
41
|
+
scoreChoices: true,
|
|
42
|
+
};
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
async embed(input: string | string[], _opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
|
|
@@ -49,6 +67,35 @@ export class TestBackend implements ModelBackend {
|
|
|
49
67
|
}
|
|
50
68
|
yield { finishReason: 'stop' };
|
|
51
69
|
}
|
|
70
|
+
|
|
71
|
+
async decide(
|
|
72
|
+
state: DecideInput,
|
|
73
|
+
schema: DecisionSchema,
|
|
74
|
+
_opts: BackendOpts<DecideOpts>
|
|
75
|
+
): Promise<ModelCallResult<DecisionOutput<unknown>>> {
|
|
76
|
+
const text = typeof state === 'string' ? state : JSON.stringify(state);
|
|
77
|
+
const output: DecisionOutput<unknown> = isObjectSchema(schema)
|
|
78
|
+
? {
|
|
79
|
+
fields: Object.fromEntries(
|
|
80
|
+
Object.entries(schema.properties).map(([name, leaf]) => [
|
|
81
|
+
name,
|
|
82
|
+
{ distribution: deterministicDistribution(`${text}\n${name}`, leaf) },
|
|
83
|
+
])
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
: { distribution: deterministicDistribution(text, schema) };
|
|
87
|
+
return { status: 'completed', output, usage: { promptTokens: text.length, latencyMs: 0 } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async scoreChoices(
|
|
91
|
+
input: GenerateInput,
|
|
92
|
+
choices: readonly string[],
|
|
93
|
+
_opts: BackendOpts<ScoreChoicesOpts>
|
|
94
|
+
): Promise<ModelCallResult<ChoiceScores>> {
|
|
95
|
+
const text = stringFromInput(input);
|
|
96
|
+
const logLikelihoods = choices.map((choice) => deterministicVector(`${text}\n${choice}`, 1)[0] * 4);
|
|
97
|
+
return { status: 'completed', output: { logLikelihoods }, usage: { promptTokens: text.length, latencyMs: 0 } };
|
|
98
|
+
}
|
|
52
99
|
}
|
|
53
100
|
|
|
54
101
|
function stringFromInput(input: GenerateInput): string {
|
|
@@ -57,6 +104,14 @@ function stringFromInput(input: GenerateInput): string {
|
|
|
57
104
|
return messages.map((m) => m.content).join(' ');
|
|
58
105
|
}
|
|
59
106
|
|
|
107
|
+
/** A complete distribution over the leaf's values, seeded by the text so the same input always decides the same way. */
|
|
108
|
+
function deterministicDistribution(text: string, leaf: DecisionLeaf): DecisionOutcome[] {
|
|
109
|
+
const values = allowedValues(leaf);
|
|
110
|
+
const weights = Array.from(deterministicVector(text, values.length), (w) => (w + 1) / 2 + 0.01);
|
|
111
|
+
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
112
|
+
return values.map((value, i) => ({ value, probability: weights[i] / total }));
|
|
113
|
+
}
|
|
114
|
+
|
|
60
115
|
/**
|
|
61
116
|
* Hash text into a deterministic Float32Array of the given dimension.
|
|
62
117
|
*
|