@happyvertical/smrt-inference 0.0.0-stage → 0.60.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/AGENTS.md +201 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +73 -2
  5. package/agents/fusions.md +92 -0
  6. package/agents/hosted.md +145 -0
  7. package/agents/resolver.md +65 -0
  8. package/agents/settings.md +84 -0
  9. package/dist/__smrt-register__.d.ts +2 -0
  10. package/dist/__smrt-register__.d.ts.map +1 -0
  11. package/dist/chunks/InferenceOverrideCollection-wl7sI-3C.js +398 -0
  12. package/dist/chunks/InferenceOverrideCollection-wl7sI-3C.js.map +1 -0
  13. package/dist/chunks/provider-config-4Bx8N52X.js +1847 -0
  14. package/dist/chunks/provider-config-4Bx8N52X.js.map +1 -0
  15. package/dist/classify.d.ts +32 -0
  16. package/dist/classify.d.ts.map +1 -0
  17. package/dist/config.d.ts +89 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/errors.d.ts +51 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/executor.d.ts +45 -0
  22. package/dist/executor.d.ts.map +1 -0
  23. package/dist/fusion-store.d.ts +16 -0
  24. package/dist/fusion-store.d.ts.map +1 -0
  25. package/dist/fusion.d.ts +155 -0
  26. package/dist/fusion.d.ts.map +1 -0
  27. package/dist/gate.d.ts +143 -0
  28. package/dist/gate.d.ts.map +1 -0
  29. package/dist/gate.js +94 -0
  30. package/dist/gate.js.map +1 -0
  31. package/dist/hosted.d.ts +454 -0
  32. package/dist/hosted.d.ts.map +1 -0
  33. package/dist/index.d.ts +9 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +8 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/manifest.json +453 -0
  38. package/dist/models/InferenceOverride.d.ts +38 -0
  39. package/dist/models/InferenceOverride.d.ts.map +1 -0
  40. package/dist/models/InferenceOverrideCollection.d.ts +35 -0
  41. package/dist/models/InferenceOverrideCollection.d.ts.map +1 -0
  42. package/dist/models.d.ts +11 -0
  43. package/dist/models.d.ts.map +1 -0
  44. package/dist/models.js +2 -0
  45. package/dist/node.d.ts +110 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +1361 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/provider-config.d.ts +113 -0
  50. package/dist/provider-config.d.ts.map +1 -0
  51. package/dist/register.d.ts +30 -0
  52. package/dist/register.d.ts.map +1 -0
  53. package/dist/resolver.d.ts +166 -0
  54. package/dist/resolver.d.ts.map +1 -0
  55. package/dist/resolver.js +288 -0
  56. package/dist/resolver.js.map +1 -0
  57. package/dist/server.d.ts +24 -0
  58. package/dist/server.d.ts.map +1 -0
  59. package/dist/server.js +985 -0
  60. package/dist/server.js.map +1 -0
  61. package/dist/settings/cache.d.ts +5 -0
  62. package/dist/settings/cache.d.ts.map +1 -0
  63. package/dist/settings/keys.d.ts +70 -0
  64. package/dist/settings/keys.d.ts.map +1 -0
  65. package/dist/settings/layers.d.ts +54 -0
  66. package/dist/settings/layers.d.ts.map +1 -0
  67. package/dist/settings/override-service.d.ts +119 -0
  68. package/dist/settings/override-service.d.ts.map +1 -0
  69. package/dist/settings/preferences.d.ts +137 -0
  70. package/dist/settings/preferences.d.ts.map +1 -0
  71. package/dist/settings/resolver.d.ts +34 -0
  72. package/dist/settings/resolver.d.ts.map +1 -0
  73. package/dist/settings/settings-service.d.ts +282 -0
  74. package/dist/settings/settings-service.d.ts.map +1 -0
  75. package/dist/settings/targets.d.ts +93 -0
  76. package/dist/settings/targets.d.ts.map +1 -0
  77. package/dist/smrt-knowledge.json +335 -0
  78. package/dist/smrt-knowledge.okf.md +80 -0
  79. package/dist/svelte/components/InferenceAliasEditor.svelte +210 -0
  80. package/dist/svelte/components/InferenceAliasEditor.svelte.d.ts +13 -0
  81. package/dist/svelte/components/InferenceAliasEditor.svelte.d.ts.map +1 -0
  82. package/dist/svelte/components/InferenceDefaultView.svelte +330 -0
  83. package/dist/svelte/components/InferenceDefaultView.svelte.d.ts +15 -0
  84. package/dist/svelte/components/InferenceDefaultView.svelte.d.ts.map +1 -0
  85. package/dist/svelte/components/InferenceFusionList.svelte +135 -0
  86. package/dist/svelte/components/InferenceFusionList.svelte.d.ts +16 -0
  87. package/dist/svelte/components/InferenceFusionList.svelte.d.ts.map +1 -0
  88. package/dist/svelte/components/InferenceSettingsPanel.svelte +345 -0
  89. package/dist/svelte/components/InferenceSettingsPanel.svelte.d.ts +65 -0
  90. package/dist/svelte/components/InferenceSettingsPanel.svelte.d.ts.map +1 -0
  91. package/dist/svelte/components/InferenceTierRow.svelte +199 -0
  92. package/dist/svelte/components/InferenceTierRow.svelte.d.ts +14 -0
  93. package/dist/svelte/components/InferenceTierRow.svelte.d.ts.map +1 -0
  94. package/dist/svelte/components/InferenceUsageTable.svelte +127 -0
  95. package/dist/svelte/components/InferenceUsageTable.svelte.d.ts +15 -0
  96. package/dist/svelte/components/InferenceUsageTable.svelte.d.ts.map +1 -0
  97. package/dist/svelte/index.d.ts +17 -0
  98. package/dist/svelte/index.d.ts.map +1 -0
  99. package/dist/svelte/index.js +14 -0
  100. package/dist/svelte/types.d.ts +197 -0
  101. package/dist/svelte/types.d.ts.map +1 -0
  102. package/dist/svelte/types.js +96 -0
  103. package/dist/types.d.ts +47 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +13 -0
  106. package/dist/types.js.map +1 -0
  107. package/package.json +105 -3
package/AGENTS.md ADDED
@@ -0,0 +1,201 @@
1
+ # smrt-inference
2
+
3
+ Binding behavior for core's inference tiers (epic #4749). Core owns the names
4
+ and the seam ([core inference.md](../core/agents/inference.md)); this package
5
+ owns alias chains, classified fallback, ladder degradation and
6
+ `smrt doctor --ai` (#4752), the fusion interpreter (#4753), the settings
7
+ panel with layered binding storage (#4754), per-user and per-tenant
8
+ bindings (#4756), candidate capability checks (#4758), and hosted
9
+ provisioning and the spend gate (#4759, #4760). App-author guide:
10
+ [docs/content/inference.md](../../docs/content/inference.md).
11
+
12
+ ## Entries
13
+
14
+ - Root (browser-safe): importing it calls `registerInference()`, which sets
15
+ the executor (`alias`, `candidates`, `fusion`) and a validating resolver
16
+ (`setInferenceResolver()`). The resolver binds nothing: it validates chain
17
+ config, then defers to a resolver registered earlier (#4756 per-user
18
+ bindings, `./resolver`) or to core's built-in config resolution. No aliases and no chain
19
+ targets means no change in behavior. `browser-safe.test.ts` holds the graph
20
+ free of `node:`, the AI SDK and smrt-config by walking its import graph;
21
+ the bundle-gate browser fixture also builds it (the package has a model).
22
+ - `./node`: `checkInferenceBindings()`, `probeInferenceCandidate()`,
23
+ `inferenceProbeUrl()`, `formatInferenceDoctorReport()`; the CLI's
24
+ `doctor --ai` and #4754's test connection call these. Also hosted
25
+ provisioning (`src/hosted.ts`, #4759).
26
+ - `./resolver` (Node-only, #4756): `createUserInferenceResolver()` and
27
+ `registerUserInferenceResolver()` over smrt-secrets / tenancy / users. The
28
+ root re-exports only the browser-safe stored-config shape
29
+ (`src/provider-config.ts`): parse, redact, tier → target map.
30
+ - `./models` (browser-safe, held by `browser-safe.test.ts` and the
31
+ bundle-gate browser fixture): the `_smrt_inference_overrides` model;
32
+ generated consumer registration imports it from here.
33
+ - `./server` (Node-only): settings service, override service, override
34
+ resolver, key rule, preference kind (#4754). `./svelte`: the panel.
35
+ - `./gate` (Node-only, #4760): the hosted spend gate and kill switch
36
+ (below). `browser-safe.test.ts` keeps it off the root graph.
37
+
38
+ ## Per-user and per-tenant bindings (#4756)
39
+
40
+ `createUserInferenceResolver()` resolves per tier: the user's stored config,
41
+ the tenant's, `fallback`, the app `inference` block, then the configured
42
+ client; every resolution carries `target.source` (`byo | local | hosted |
43
+ app`, the `source` usage tag). Keys live only in the smrt-secrets type
44
+ `inference.provider-config` (`context = tenantId`), every stored binding is
45
+ self-contained (own `baseUrl` + `apiKey`, or a preset), the user layer
46
+ applies only to a principal of that tenant, and agents pass `userId: null`
47
+ (tenant layer only). The stored config also carries `mode` (#4757).
48
+ Details: [agents/resolver.md](agents/resolver.md).
49
+
50
+ ## Resolver composition
51
+
52
+ One resolver is registered, outermost first: the #4752 validating wrapper,
53
+ the #4756 user resolver, its `fallback`, then core's config resolution.
54
+ Hosts using the #4754 override table pass
55
+ `createInferenceOverrideResolver({ db })` as that `fallback`:
56
+ `registerUserInferenceResolver(createUserInferenceResolver({ db, fallback:
57
+ createInferenceOverrideResolver({ db }) }))`. Order per tier: user stored
58
+ binding, tenant stored binding, allowed tenant override row, app override
59
+ row, `inference` config, configured client. Keys are stored once, in
60
+ `inference.provider-config`: the host turns #4754's origin-bound
61
+ `bindInferenceKey()` record into a self-contained binding with
62
+ `inferenceProviderConfigFromKey(record, { model })` and writes it through
63
+ the user resolver's `write(level, config)`; never a second secret type.
64
+
65
+ ## Hosted inference (#4759, #4760)
66
+
67
+ `./node` provisions per-user gateway keys through `ai.admin` of the
68
+ `bifrost` / `litellm` provider (`SMRT_INFERENCE_HOSTED_*`, read once), storing
69
+ the key only in the user's `inference.provider-config` (`source: 'hosted'`).
70
+ Budget update and key delete wait on happyvertical/sdk#1494 (feature-detected;
71
+ revoke ends local use first and records owed deletions). `./gate`'s
72
+ `registerHostedInferenceGate({ killSwitch, spend })` is core's
73
+ `InferenceGate`: kill switch `inference.hosted.enabled` (a global DISABLE
74
+ always wins), tenant, entitlement, then plan spend caps; a block throws
75
+ `InferenceUnavailableError` (`entitlement`) and writes a `blocked` usage row.
76
+ Details: [agents/hosted.md](agents/hosted.md).
77
+
78
+ ## Config
79
+
80
+ ```ts
81
+ config({ inference: {
82
+ aliases: { frontier: { candidates: [
83
+ { provider: 'anthropic', model: 'claude-…' }, // provider key var
84
+ { provider: 'openai', model: 'gpt-…', baseUrl, apiKeyEnv }, // own key + endpoint
85
+ { preset: 'lemonade', model: 'qwen…' }, // #4674 preset
86
+ { alias: 'local' }, // expanded in place
87
+ ] } },
88
+ tiers: { great: { alias: 'frontier' }, cheap: { candidates: [ … ] } },
89
+ degrade: true, // default
90
+ } });
91
+ ```
92
+
93
+ A candidate is exactly one of provider/model, preset/model, or alias.
94
+ `apiKeyEnv` is read at call time and needs a `baseUrl` (core's pair rule: a
95
+ key only travels with its own endpoint); a named variable that is unset skips
96
+ the candidate as `credential`. `findInferenceConfigIssues()` /
97
+ `validateInferenceConfig()` name every offender by path: empty alias, unknown
98
+ alias (tier or nested), cycle, key without endpoint, unknown preset. The
99
+ executor also validates every resolved target it runs (a resolver's chains
100
+ never pass through config), and a candidate key is never dropped: without
101
+ its own `baseUrl` core's pair rule refuses it.
102
+
103
+ ## Executor (`src/executor.ts`)
104
+
105
+ - Free of SDK imports: it drives the clients core builds through
106
+ `context.clientFor(target)`; tests use fake clients.
107
+ - Failure classes (`classify.ts`): `quota | credential | missing-model |
108
+ transient | refusal | missing-capability`, from error class names, `code`, `retryable` and HTTP
109
+ status across `cause` / `error` / `context.originalError`, never message
110
+ text. The SDK maps 5xx and other HTTP failures to `API_ERROR` /
111
+ `UNKNOWN_ERROR` without a status or cause (happyvertical/sdk#1493); those
112
+ count as `transient`. A definitive client error that survives in the chain
113
+ (other 4xx, `invalid_request_error`, context length) is not a routing
114
+ failure. Core's typed bind refusal (`INFERENCE_TARGET_UNBOUND`: pair rule,
115
+ `HAVE_AI_*`) is `credential` and advances. Aborts and unclassified errors stop the walk and are rethrown (an
116
+ unclassified one is still recorded).
117
+ - `transient` retries the same candidate once after 200–500 ms of jitter;
118
+ every class then advances. A `refusal` finish reason with nothing streamed
119
+ also advances; finish callbacks are replayed only for the kept answer.
120
+ - Streamed text is never extended: a candidate that emitted text and then
121
+ failed ends the walk with a `partial` substitution and
122
+ `InferenceExhaustedError` (`reason: 'partial'`, `partialText`), unless
123
+ `allowPartial` returns the text. A hosted-gate block (`AI_UNAVAILABLE`
124
+ from `clientFor()`, #4760) stops the walk and records nothing (core wrote
125
+ the `blocked` row). A step whose target names a `source` (a ladder step's
126
+ resolved binding) records that source over the chain's, so hosted spend
127
+ meters as hosted; `expandTarget()` hands a chain's own `source` to its
128
+ candidate steps, so a hosted chain reached on a ladder step is gated in
129
+ `clientFor()` and meters as hosted too. A `stream()` consumer that stops reading
130
+ aborts the provider stream (internal signal merged with the caller's).
131
+ - Ladder: when a tier's chain is spent and `degrade !== false`, resolve the
132
+ next lower tier through `context.resolveTier()`, stopping below
133
+ `minimumTier`. Never up. Steps already tried (same identity) are skipped,
134
+ so unbound lower tiers try the configured client once.
135
+ - Tags on the answering call: caller tags, `tierRequested`, `tierResolved`
136
+ (the tier that answered), and `substitutions` (JSON array of
137
+ `{ from, to, reason }`, labels `provider/model`, `preset:<id>/model`,
138
+ `default`). A call that never answered records one zero-token row through
139
+ `context.recordUsage()` (core's usage handlers; no second writer) with
140
+ `outcome: failed | partial`, `reason` and the full trail ending `to: null` (reason `unclassified` when a non-routing error ended it),
141
+ then throws `InferenceExhaustedError` (`INFERENCE_EXHAUSTED`).
142
+ - Capabilities (#4758): before calling a candidate, a call that sends
143
+ `responseFormat` (JSON) or `tools` checks `context.capabilitiesFor(target)`
144
+ (core's detection, [inference.md](../core/agents/inference.md)). A
145
+ mismatch the caller's `capabilityFallback` option permits (`json: 'text'`,
146
+ `tools: 'drop'`) runs the candidate without the feature, tagged
147
+ `capabilityFallback`; any other is a `missing-capability` substitution and
148
+ the walk advances with no model call. The option never reaches a provider.
149
+ Core's `InferenceUnavailableError` (`reason: 'capability'`) also classifies
150
+ as `missing-capability`. No detector (older core) or no requirement: no
151
+ check. Context limits are core's (single targets only).
152
+ - Add a class to `INFERENCE_FAILURE_CLASSES` and a kind to `EXECUTOR_KINDS`
153
+ (`createChainExecutor()` in `fusion.ts` dispatches on it); consumers
154
+ ignore kinds they do not run.
155
+
156
+ ## Fusions (#4753)
157
+
158
+ `{ fusion: '<key>' }` tier targets run consumer-defined fusions (`single` 1
159
+ call, `pair` 3, `handoff` 2) from `inference.fusions` (wins) or
160
+ `defineFusion()`. SMRT ships the interpreter, never a fusion. Seats are
161
+ smrt-prompts keys run through `createChainClient()`; a dead seat never
162
+ aborts a `pair`, all seats dead throws `InferenceFusionError`; each run writes
163
+ one `_smrt_ai_runs` row. Fusions are defined at app scope only; a tenant
164
+ may bind a tier to an allow-listed fusion, never define one. Details:
165
+ [agents/fusions.md](agents/fusions.md).
166
+
167
+ ## Doctor (`src/node.ts`)
168
+
169
+ Per tier, alias and fusion (structure only): provider recognized (AI SDK provider list or preset id),
170
+ key variable present (a provider's default variable only warns: a
171
+ same-provider target inherits the configured key), model named (warns),
172
+ alias resolvable, cycle-free. `probe` GETs `/models` (OpenAI-compatible,
173
+ Anthropic, Gemini) or Ollama `/api/tags` with a 3 s timeout, sending the key
174
+ only to its own endpoint. A binding errors when its config is invalid or no
175
+ candidate can answer; one bad candidate in a working chain warns. Keys never
176
+ appear in the report. With `probe` each candidate also reports its
177
+ capabilities and their source (#4758, `formatInferenceCapabilities()`): the
178
+ listing's context length, `supported_parameters` and input modalities
179
+ (`inferenceCapabilitiesFromModelListing()`) are the `probe` layer; the SDK
180
+ `provider` layer only through the `sdkCapabilities` option (the doctor builds
181
+ no SDK clients).
182
+
183
+ ## Settings UI and binding storage (#4754)
184
+
185
+ `./svelte` `InferenceSettingsPanel`, `./server` `InferenceSettingsService` /
186
+ `InferenceOverrideService` / `createInferenceOverrideResolver()`, `./models`
187
+ the `_smrt_inference_overrides` model (ships through `smrt db:migrate`).
188
+ Keys go to smrt-secrets through the host and are bound to their endpoint's
189
+ origin; user choices are the smrt-preferences kind `inference`; app/tenant
190
+ bindings are override rows, tenants limited to the app row's allow-list and
191
+ the `tenantTargetPolicy` hook. Details: [agents/settings.md](agents/settings.md),
192
+ consumer guide [docs/content/inference-settings.md](../../docs/content/inference-settings.md).
193
+
194
+ ## Validation
195
+
196
+ ```bash
197
+ pnpm --filter @happyvertical/smrt-inference test
198
+ pnpm --filter @happyvertical/smrt-inference test:postgres # hosted provisioning on PostgreSQL (advisory lock, runOnce)
199
+ pnpm --filter @happyvertical/smrt-inference typecheck
200
+ pnpm --filter @happyvertical/smrt-inference build
201
+ ```
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # @happyvertical/smrt-inference
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fallback for s-m-r-t inference tiers. Bind a tier to an ordered chain of models
4
+ (an alias) and a call that hits a quota, a bad key or a renamed model moves to
5
+ the next model instead of failing. Every move is recorded on the call's usage
6
+ row, and `smrt doctor --ai` checks the bindings before you need them.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pnpm add @happyvertical/smrt-inference
12
+ ```
13
+
14
+ Import it once at startup. Importing registers it with `@happyvertical/smrt-core`.
15
+
16
+ ```ts
17
+ import '@happyvertical/smrt-inference';
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { config } from '@happyvertical/smrt-core';
24
+
25
+ config({
26
+ inference: {
27
+ aliases: {
28
+ frontier: {
29
+ candidates: [
30
+ { provider: 'anthropic', model: 'claude-sonnet-4-5' },
31
+ { provider: 'openai', model: 'gpt-5' },
32
+ { preset: 'lemonade', model: 'qwen3-8b' },
33
+ ],
34
+ },
35
+ },
36
+ tiers: { great: { alias: 'frontier' } },
37
+ },
38
+ });
39
+
40
+ await article.do('Summarize this', { tier: 'great' });
41
+ ```
42
+
43
+ - A failed candidate is classified as `quota`, `credential`, `missing-model`,
44
+ `transient` (retried once), `refusal` or `missing-capability` (the call
45
+ needs JSON or tools the model lacks), and the next one is tried.
46
+ - When all of a tier's candidates fail, the call moves down to the next
47
+ cheaper tier. Set `inference.degrade: false` to stop that, or pass
48
+ `minimumTier` on the call (prompts: `ai.minimumTier`).
49
+ - Text already streamed to the caller is never continued by another model.
50
+ - The usage row of the answering call carries `tierResolved` and
51
+ `substitutions`. A call that nothing answered leaves a zero-token row with
52
+ the reason and throws `InferenceExhaustedError`.
53
+
54
+ ## Beyond fallback
55
+
56
+ - **Fusions**: bind a tier to a fusion you define (`single`, `pair` or
57
+ `handoff` over smrt-prompts keys); callers keep asking for the tier.
58
+ - **Bindings**: users and tenants bind tiers through stored configs
59
+ (`./resolver`) and the settings panel (`./svelte`, `./server`), tenants only
60
+ to targets on the app's allow-list; keys stay in smrt-secrets.
61
+ - **Hosted inference**: per-user gateway keys (`./node`) and the spend gate
62
+ and kill switch (`./gate`).
63
+
64
+ The app-author guide, with a worked example, is
65
+ [Inference tiers, bindings and usage](../../docs/content/inference.md).
66
+
67
+ ## Checking bindings
68
+
69
+ ```bash
70
+ smrt doctor --ai # offline: providers, key variables, models, aliases, cycles
71
+ smrt doctor --ai --probe # also asks each endpoint for its model list
72
+ ```
73
+
74
+ See [AGENTS.md](./AGENTS.md) for the full contract.
@@ -0,0 +1,92 @@
1
+ # Fusions (`src/fusion.ts`, #4753)
2
+
3
+ `src/fusion.ts` runs `{ fusion: '<key>' }` tier targets. SMRT ships the
4
+ interpreter and the vocabulary, never a fusion: modes are code, fusions are
5
+ config. A fusion comes from `inference.fusions.<key>` (app config, wins) or
6
+ `defineFusion({ key, mode, seats, gate?, description? })` (process registry
7
+ on `globalThis`). A seat is `{ name?, prompt, target, temperature?, output? }`:
8
+ `prompt` is a smrt-prompts key whose resolved template (code default,
9
+ config, app and tenant overrides, read through `context.db`) is the seat's
10
+ system message, so tenant overrides of a seat need nothing new; `target` is
11
+ a tier (resolved through the same resolver with the seat's prompt key, so
12
+ per-user bindings apply), `{ alias }`, `{ candidates }` or an inline
13
+ `{ provider, model }`, never a fusion. Each seat runs through
14
+ `createChainClient()`, so chains, classified fallback and (tier seats only)
15
+ the ladder apply per seat. `name` defaults to the prompt key and must be
16
+ unique.
17
+
18
+ Example (a consumer's code; SMRT does not ship it):
19
+
20
+ ```ts
21
+ definePrompt({ key: 'contracts.skeptic', template: 'List the risks…' });
22
+ definePrompt({ key: 'contracts.advocate', template: 'List the benefits…' });
23
+ definePrompt({ key: 'contracts.synthesis', template: 'Weigh both reviews…' });
24
+ config({ inference: {
25
+ tiers: { best: { fusion: 'contract-review' } },
26
+ fusions: { 'contract-review': {
27
+ mode: 'pair',
28
+ seats: [
29
+ { name: 'skeptic', prompt: 'contracts.skeptic', target: 'good' },
30
+ { name: 'advocate', prompt: 'contracts.advocate', target: { alias: 'frontier' } },
31
+ { name: 'synthesis', prompt: 'contracts.synthesis', target: 'great' },
32
+ ],
33
+ gate: { criteria: 'Both reviews agree on every material risk.',
34
+ sufficientWhen: { choice: 'agree', minConfidence: 0.85 } },
35
+ } },
36
+ } });
37
+ await contract.do('Summarize this contract', { tier: 'best' }); // unchanged caller
38
+ ```
39
+
40
+ - **Modes and call counts** (model calls; a seat's fallback candidates are
41
+ substitutions of that seat, not extra seats): `single` 1 (one seat);
42
+ `pair` 3 (seats 0 and 1 in parallel, then seat 2 merges both outputs,
43
+ labelled by seat and model); `handoff` 2 (seat 0 drafts, seat 1 receives
44
+ the draft and answers). A mode that adds a call is a bug.
45
+ - **Gate** (`pair`, `handoff`): one choice question through
46
+ `context.decide()` (core's typed-decision client, the `evaluate()` path)
47
+ over the outputs so far, labels `sufficientWhen.choice` and `continue`.
48
+ The chosen label at `minConfidence` or above skips the remaining stage,
49
+ recorded `skipped`; the answer is then seat 0's output (`pair`: the first
50
+ live one) or the draft. No decision client (`unavailable`) or a decision
51
+ error runs the stage. Its usage row has `seat: 'gate'`, a reserved seat
52
+ name.
53
+ - **Failure semantics.** A dead seat never aborts a `pair`: the merge seat
54
+ is told which seat failed and why (class and message). A dead merge or
55
+ handoff answer seat falls back to the first live output, recorded
56
+ `degraded`; the handoff answer seat is told when the draft died. A run
57
+ whose seats all died throws `InferenceFusionError`
58
+ (`INFERENCE_FUSION_FAILED`, `result` holds every seat) with the run
59
+ recorded `failed`, never an empty answer. A seat whose setup fails (prompt
60
+ override read, its tier's resolver) dies like a model failure. A config
61
+ error, a caller abort, or a final seat that already streamed text
62
+ (`partial`) rethrows, after both `pair` seats have settled.
63
+ - **JSON**: `output: 'json'` sends `responseFormat: json_object` and parses;
64
+ a seat whose text does not parse keeps it under `raw` (with `parseError`)
65
+ in `FusionSeatResult` and hands the raw text on, labelled. A candidate
66
+ without JSON mode runs the seat in text on that recovery path
67
+ (`capabilityFallback: { json: 'text' }`, #4758) instead of being skipped.
68
+ - **Caller options**: generation options (`responseFormat`, `tools`,
69
+ `onProgress`, …) reach the final seat only; `signal` / `timeout` reach every
70
+ seat; `history` is the seat's instructions. `onFusionResult(result)`
71
+ receives the structured `FusionRunResult`. Only `message()` runs a fusion.
72
+ - **Record**: every seat's usage row carries `runId`, `fusionKey`, `seat`
73
+ and the seat's prompt tags; `context.recordRun()` writes one
74
+ `_smrt_ai_runs` row (`ok | degraded | failed`; a seat is degraded when it
75
+ died or answered through a substitution) with identities and totals only
76
+ (`../../core/agents/ai-usage.md`). `smrt ai:usage --by fusion` totals runs.
77
+ - **Validation**: `findInferenceConfigIssues()` names structural offenders
78
+ by path (`fusion-invalid`, `fusion-unknown`, `fusion-nested`); an
79
+ unregistered seat prompt key is a load error (`fusion-prompt-unknown`) from
80
+ `validateInferenceConfig()`, the resolver, the executor and
81
+ `defineFusion()`, but not the doctor (it runs without the app's prompts).
82
+ - Seat prompts resolve through `@happyvertical/smrt-prompts` (a dependency;
83
+ never a peer, #1582), imported by a variable specifier from this package
84
+ (strict layouts find it; bundlers do not follow it, so the root stays browser-safe),
85
+ then core's `importOptionalDependency()` (a module registered with
86
+ `registerOptionalDependency()` in bundled builds).
87
+ `setFusionPromptResolver()` replaces it.
88
+ - **Fusions are app-scope only.** A fusion multiplies hosted model spend
89
+ per call, so a tenant never defines or redefines one; with #4754's
90
+ allow-list a tenant may bind a tier to a listed fusion (`fusion:<n>`).
91
+ The executor reads fusions from app config and `defineFusion()`; a fusion
92
+ defined only in an app override row fails `fusion-unknown` until #4800.
@@ -0,0 +1,145 @@
1
+ # Hosted inference (#4759, #4760)
2
+
3
+ ## Provisioning (`src/hosted.ts`, #4759)
4
+
5
+ `provisionHostedInference({ db, tenantId, userId, budget })`,
6
+ `revokeHostedInference({ db, tenantId, userId })`, or one
7
+ `createHostedInferenceProvisioner({ db, secrets?, gateway?, admin?, actor? })`
8
+ (`provision`, `revoke`, `syncBudget`, `status`, `listActive`, `listKnown`,
9
+ `revokeTenant`, `syncTenantBudget`, `reconcileTenant`,
10
+ `acknowledgeGatewayRevocation` to drop an owed deletion an operator did by
11
+ hand).
12
+
13
+ - **Gateway.** Admin calls go through `ai.admin` (`AIAdminInterface`) of the
14
+ `bifrost` / `litellm` provider of `@happyvertical/ai`; never reimplement
15
+ gateway administration here. The SDK has `createProject` and
16
+ `createVirtualKey` only: `updateVirtualKey` / `deleteVirtualKey` are
17
+ feature-detected on `HostedGatewayAdmin` (happyvertical/sdk#1494). Without
18
+ them budget sync returns `unsupported` (state keeps the old budget so a
19
+ later sync retries). Revoke ends local use before touching the gateway
20
+ (config restored, state retired with the key owed, then the delete); a
21
+ deletion the gateway cannot perform (`unsupported`,
22
+ `failed`, or a key a failed provision left) goes to the state's
23
+ `pendingGatewayRevocations` (id + alias, never the key), is audited with
24
+ `manualGatewayRevocation`, and is retried by `revoke()` /
25
+ `reconcileTenant()`.
26
+ - **Config, read once from the environment** (`getHostedGatewayConfig()`;
27
+ unset provider = hosted off): `SMRT_INFERENCE_HOSTED_PROVIDER`
28
+ (`bifrost|litellm`), `_BASE_URL` (the OpenAI-compatible endpoint keys are
29
+ used against), `_ADMIN_URL`, `_ADMIN_KEY` (LiteLLM master key, required for
30
+ litellm; or a Bifrost admin key), `_ADMIN_USER` / `_ADMIN_PASSWORD`
31
+ (Bifrost Basic auth), `_MODEL`, `_<TIER>_MODEL`, `_MODELS` (comma list the
32
+ key may use; default the binding's models), `_KEY_DURATION`,
33
+ `_ADMIN_TIMEOUT_MS` (deadline per admin call, default 30 s, also passed as
34
+ the SDK `timeout`; a call past it is a failure, so a stalled gateway never
35
+ holds the lifecycle lock). A create that fails ambiguously (timeout,
36
+ transport reset, 5xx, unclassified; anything but a credential, quota,
37
+ missing-model or refusal answer per `classifyInferenceFailure`) records
38
+ its alias as owed, since the gateway may have minted the key.
39
+ Errors name missing fields, never values.
40
+ - **Storage** (smrt-secrets, `context = tenantId`): the key lives only in
41
+ the user's `inference.provider-config:user:<id>` (`source: 'hosted'`,
42
+ `mode: 'hosted'`, `gatewayKeyAlias`, a binding per configured tier);
43
+ `inference.hosted:user:<id>` (category `inference.hosted`) holds status,
44
+ generation, key id and alias, budget and the user's prior config;
45
+ `inference.hosted:project` the tenant's gateway project (created once
46
+ through `runOnce()`, LiteLLM id `smrt-<tenantId>-<nonce>`). Every write passes
47
+ `auditDetails.operation` (`inference.hosted.provision|revoke|budget|project`)
48
+ to `SecretAuditLog`.
49
+ - **Idempotency and ordering.** An active grant whose binding is still the
50
+ stored one is returned (`created: false`), its budget synced when it
51
+ differs. The create path runs under `runOnce()` keyed by `(tenantId,
52
+ userId, generation)`; revoke advances the generation. Aliases
53
+ (`smrt-<tenant>-<user>-g<n>-<nonce>`) and LiteLLM project ids
54
+ (`smrt-<tenant>-<nonce>`) are unique per attempt, so a failed attempt's
55
+ leftovers never block the retry. Lifecycle operations run one at a time
56
+ per process (a process-wide chain: rare operations, and it bounds the
57
+ pooled connections held) and, on PostgreSQL, under a per-tenant
58
+ `pg_advisory_xact_lock` held in a transaction for the whole operation
59
+ (its writes use other pooled connections: at most three per process), so
60
+ a tenant-wide revoke or reconcile never misses an in-flight provision.
61
+ The optional `entitled(tenantId)` check runs inside that lock before a
62
+ key is minted (`HOSTED_NOT_ENTITLED`) and before an entitled reconcile
63
+ acts (a stale snapshot then revokes), so a stale gain or late retry after
64
+ a downgrade does not activate. Hosts running the listener or reconciler
65
+ across processes should set it. A failed binding write deletes or records the new
66
+ key and restores the previous state after the claim rolls back (no delete
67
+ inside the claim transaction: single-connection adapters would wait on
68
+ it). A user who replaced the hosted binding keeps their config: an
69
+ explicit `provision()` retires the stale key and mints anew; a reconcile
70
+ only retires it and marks the state `optedOut`, which later reconciles
71
+ respect until an explicit `provision()`. Writes through the #4756
72
+ resolver (the settings panel) do not take this lock.
73
+ - **Revoke** restores the prior config when it holds a binding or a mode,
74
+ else writes `{ mode: 'off' }`; a config the user replaced is kept.
75
+ - **Budget** is integer micro-dollars (`{ limitMicros, window? }`, `day |
76
+ week | month | year`, default month), converted to the gateway's USD
77
+ `maxLimit` and `1d|7d|30d|365d` only at the admin call. #4760's spend gate
78
+ is the authoritative pre-call check; the gateway budget is defense in depth.
79
+ An unchanged budget is never re-sent (a gateway update may restart the
80
+ spend window); after a failed or timed-out update (`budgetSyncUncertainAt`,
81
+ it may still land) syncs re-apply the stored budget until a confirmed
82
+ update 15 minutes later.
83
+ - **Entitlement hook.** `createHostedInferenceEntitlementListener({
84
+ provisioner, users, budgetFor?, defaultBudget? })` structurally matches
85
+ smrt-subscriptions' `EntitlementChangeListener` (register it with
86
+ `registerEntitlementChangeListener()`; the host wraps its webhook write in
87
+ `trackEntitlementChange()`). A change touching `inference.hosted`
88
+ reconciles the tenant to the change's `after` snapshot through
89
+ `createHostedInferenceReconciler()`: entitled provisions `users(tenantId)`
90
+ (membership is the host's; exclude users an operator revoked) unless
91
+ they opted out, and syncs budgets; not entitled revokes every grant; both
92
+ retry owed deletions (also for active grants: they are old keys). The
93
+ plan's budget is the grant's `metadata.budget` (`{ limitMicros, window }`).
94
+ A replayed webhook diffs to "no change", so hosts also call the reconciler
95
+ with `snapshotEntitlements()` on redelivery and on a schedule; that is
96
+ what repairs a reaction lost to a crash. Per-user failures are thrown
97
+ together after all users were tried.
98
+ - **Keys.** Never in results, errors (gateway failures carry only the
99
+ operation and error code), audit details, `runOnce()` results or logs.
100
+ Usage rows carry `source: hosted` and `gatewayKeyAlias` (from
101
+ `InferenceTarget.usageTags`). Other processes see a new binding after the
102
+ resolver cache expires; this process is invalidated through
103
+ `invalidateUserInferenceResolvers()`.
104
+
105
+ ## Spend gate and kill switch (`src/gate.ts`, #4760)
106
+
107
+ `createHostedInferenceGate({ killSwitch, spend, principal?,
108
+ requireSubscription?, onWarning? })` is core's `InferenceGate` (seam:
109
+ [core inference.md](../../core/agents/inference.md), "Hosted gate");
110
+ `registerHostedInferenceGate(gate | options)` installs it and the
111
+ principal's user as the `userId` usage tag. Host wiring:
112
+
113
+ ```ts
114
+ registerHostedInferenceGate({
115
+ killSwitch: new KillSwitchService({ db }), // smrt-features
116
+ spend: await HostedAiSpendEvaluator.create({ db }), // smrt-subscriptions
117
+ });
118
+ ```
119
+
120
+ Both stores are structural: smrt-features and smrt-subscriptions build with
121
+ the CLI, which depends on this package, so a dependency on either is a turbo
122
+ cycle. Checks, in order, on every `source: hosted` call:
123
+
124
+ 1. **Kill switch** `inference.hosted.enabled` (`HOSTED_INFERENCE_KILL_SWITCH`):
125
+ global DISABLE, then the tenant's DISABLE, blocks (`policy:
126
+ 'kill-switch'`, `disabledBy`). Uncached, no model call: **the incident
127
+ lever** (`smrt inference:hosted disable [--tenant <id>]`; programmatic
128
+ `setHostedInferenceEnabled(store, { enabled, tenantId? })`,
129
+ `getHostedInferenceSwitch()`). Stored as smrt-features override rows
130
+ with kill-switch semantics, not as a flag: a tenant ENABLE can never
131
+ lift a global DISABLE, and `inference.hosted` (the plan key) stays a pure
132
+ entitlement. Hosts with an admin UI write the same DISABLE rows through
133
+ `FeatureOverrideService` and their authorizer.
134
+ 2. **Tenant**: request tenant, else the principal's; none blocks (`no-tenant`).
135
+ 3. **Entitlement**: no entitled subscription blocks (`subscription`) unless
136
+ `requireSubscription: false`.
137
+ 4. **Spend**: the plan's tenant and per-user caps (smrt-subscriptions
138
+ AGENTS, "Hosted AI spend caps"): `blocked` blocks with `cap`, `spend`
139
+ (micro-dollars) and `resetsAt`; `warn` returns `warn` and calls
140
+ `onWarning` (errors swallowed). The user is the principal's, only in its
141
+ own tenant, and none for `userId: null` (agents).
142
+
143
+ Any thrown error propagates and core blocks with `gate-error`. The plan
144
+ check does not test the `inference.hosted` feature key: provisioning the
145
+ hosted binding (#4759) is where entitlement to hosted is granted.
@@ -0,0 +1,65 @@
1
+ # Per-user and per-tenant bindings (`src/resolver.ts`, #4756)
2
+
3
+ `createUserInferenceResolver({ db | secrets, fallback?, ttlMs?, principal? })`
4
+ is core's `InferenceResolver`. Per tier, highest first: the user's stored
5
+ config (its `tiers[tier]`, then its default binding), the tenant's (same
6
+ order), `fallback`, the app `inference` block, then the configured client
7
+ (`SMRT_AI_*` / process `ai`). Every resolution carries `target.source`
8
+ (`byo | local | hosted | app`); core records it as the `source` usage tag.
9
+
10
+ - **Registration order.** `registerUserInferenceResolver(r)` installs
11
+ `createInferenceResolver(r)`: the #4752 validating resolver stays outermost
12
+ (chain config is validated first), then the user resolver, then its
13
+ `fallback`, then core's config resolution. A later `registerInference()`
14
+ keeps it (the wrapper is marked as this package's own); a bare
15
+ `setInferenceResolver(r)` would drop the validation.
16
+ - **Storage.** One smrt-secrets type, `inference.provider-config`
17
+ (`category`), via `SecretService.store()` / `retrieve()` only, with
18
+ `context = tenantId`. Names: `inference.provider-config:user:<userId>` and
19
+ `inference.provider-config:tenant`. The value is
20
+ `{ provider, baseUrl, model, apiKey, preset, params, source?, mode?,
21
+ gatewayKeyAlias?, tiers? }`; hosted (#4759) writes the same type with
22
+ `source: 'hosted'` and `gatewayKeyAlias` (refused on any other source;
23
+ every target of the config carries it as `usageTags`).
24
+ `parseInferenceProviderConfig()` requires every binding to be
25
+ self-contained (`baseUrl` and `apiKey`, `'smrt-local'` for keyless local
26
+ servers, or a preset that supplies the endpoint), so a stored binding never
27
+ borrows the server's environment credentials; it also refuses a `baseUrl`
28
+ with userinfo, query or fragment, `params` beyond the scalar generation
29
+ options in `INFERENCE_PROVIDER_PARAM_KEYS`, a preset without a model,
30
+ unknown fields, and `source: 'app'`. Redaction copies known non-secret
31
+ fields only. A corrupt stored value fails the call with
32
+ `InferenceProviderConfigError` (paths only, never values); a missing,
33
+ disabled or expired secret is simply no layer.
34
+ - **Identity.** The ambient principal (tenancy context, then the users
35
+ session context: smrt-preferences' order). With a principal: its tenant
36
+ and user. A request for another tenant (an object of tenant B used by a
37
+ principal of A) reads nothing stored and falls through to app config,
38
+ unless `allowCrossTenant` (then B's tenant layer, no user). No principal
39
+ (jobs): the request's tenant, no user layer, never a guess.
40
+ `request.userId === null` (core `resolveInferenceTarget({ userId: null })`)
41
+ skips the user layer for clients shared beyond one user (agents); a string
42
+ `userId` is ignored.
43
+ - **Cache.** Decrypted layers per `(tenantId, userId)` for `ttlMs` (30 s);
44
+ failed loads are evicted. `write(level, config)` / `remove(level)` store
45
+ for the current principal and invalidate; `invalidate()` takes all, one
46
+ tenant (every user), or one user. Who may write the tenant config is the
47
+ host server action's decision (#4754).
48
+ - **Mode (#4757).** A stored config may carry `mode` (`byo | local | hosted
49
+ | off`) and may hold only a mode (no binding, no key). The user's mode,
50
+ else the tenant's, applies: `off` answers every tier `{ unavailable:
51
+ 'off' }` and `mode()` (untiered calls) `{ mode: 'off', unavailable: 'off' }`;
52
+ `byo` / `local` / `hosted` bind only a stored binding of that `source`
53
+ (user, then tenant), else `unavailable: 'unconfigured'`, never app config.
54
+ No mode: the order above, reporting the binding's source as `mode`.
55
+ `mode()` returns an explicit mode, which core uses to route untiered calls
56
+ through `auto` (so they bind the same way).
57
+ `setMode(level, mode | null)` keeps the binding (deletes a config left
58
+ empty) and invalidates; `write()` replaces the whole config, mode
59
+ included. `resolveBindings()` adds `mode: { mode, layer }`. The #4752
60
+ wrapper forwards `mode()` only when the base has one.
61
+ - **Keys.** Never logged or tagged. `resolveBindings()` returns the tier map
62
+ and both stored configs redacted by default (`hasApiKey`, no `apiKey`), the
63
+ shape the settings panel reads; `redact: false` is server-only.
64
+ - Raw `getAI()` sites take the binding through core's
65
+ `resolveInferenceTarget()` (see [core inference.md](../../core/agents/inference.md)).