@happyvertical/smrt-inference 0.0.0-stage → 0.60.16

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 (112) hide show
  1. package/AGENTS.md +206 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +73 -2
  5. package/agents/fusions.md +105 -0
  6. package/agents/hosted.md +161 -0
  7. package/agents/resolver.md +65 -0
  8. package/agents/settings.md +107 -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-Dat4TrK5.js +423 -0
  12. package/dist/chunks/InferenceOverrideCollection-Dat4TrK5.js.map +1 -0
  13. package/dist/chunks/provider-config-BSpIQRVM.js +1873 -0
  14. package/dist/chunks/provider-config-BSpIQRVM.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 +461 -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 +1414 -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 +291 -0
  56. package/dist/resolver.js.map +1 -0
  57. package/dist/server.d.ts +25 -0
  58. package/dist/server.d.ts.map +1 -0
  59. package/dist/server.js +1138 -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 +68 -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 +41 -0
  72. package/dist/settings/resolver.d.ts.map +1 -0
  73. package/dist/settings/settings-service.d.ts +355 -0
  74. package/dist/settings/settings-service.d.ts.map +1 -0
  75. package/dist/settings/targets.d.ts +112 -0
  76. package/dist/settings/targets.d.ts.map +1 -0
  77. package/dist/settings/transcripts.d.ts +33 -0
  78. package/dist/settings/transcripts.d.ts.map +1 -0
  79. package/dist/smrt-knowledge.json +341 -0
  80. package/dist/smrt-knowledge.okf.md +80 -0
  81. package/dist/svelte/components/InferenceAliasEditor.svelte +210 -0
  82. package/dist/svelte/components/InferenceAliasEditor.svelte.d.ts +13 -0
  83. package/dist/svelte/components/InferenceAliasEditor.svelte.d.ts.map +1 -0
  84. package/dist/svelte/components/InferenceDefaultView.svelte +330 -0
  85. package/dist/svelte/components/InferenceDefaultView.svelte.d.ts +15 -0
  86. package/dist/svelte/components/InferenceDefaultView.svelte.d.ts.map +1 -0
  87. package/dist/svelte/components/InferenceFusionList.svelte +141 -0
  88. package/dist/svelte/components/InferenceFusionList.svelte.d.ts +16 -0
  89. package/dist/svelte/components/InferenceFusionList.svelte.d.ts.map +1 -0
  90. package/dist/svelte/components/InferenceSettingsPanel.svelte +392 -0
  91. package/dist/svelte/components/InferenceSettingsPanel.svelte.d.ts +84 -0
  92. package/dist/svelte/components/InferenceSettingsPanel.svelte.d.ts.map +1 -0
  93. package/dist/svelte/components/InferenceTierRow.svelte +199 -0
  94. package/dist/svelte/components/InferenceTierRow.svelte.d.ts +14 -0
  95. package/dist/svelte/components/InferenceTierRow.svelte.d.ts.map +1 -0
  96. package/dist/svelte/components/InferenceTranscriptSettings.svelte +165 -0
  97. package/dist/svelte/components/InferenceTranscriptSettings.svelte.d.ts +18 -0
  98. package/dist/svelte/components/InferenceTranscriptSettings.svelte.d.ts.map +1 -0
  99. package/dist/svelte/components/InferenceUsageTable.svelte +177 -0
  100. package/dist/svelte/components/InferenceUsageTable.svelte.d.ts +21 -0
  101. package/dist/svelte/components/InferenceUsageTable.svelte.d.ts.map +1 -0
  102. package/dist/svelte/index.d.ts +17 -0
  103. package/dist/svelte/index.d.ts.map +1 -0
  104. package/dist/svelte/index.js +14 -0
  105. package/dist/svelte/types.d.ts +262 -0
  106. package/dist/svelte/types.d.ts.map +1 -0
  107. package/dist/svelte/types.js +124 -0
  108. package/dist/types.d.ts +47 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/types.js +13 -0
  111. package/dist/types.js.map +1 -0
  112. package/package.json +105 -3
package/AGENTS.md ADDED
@@ -0,0 +1,206 @@
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
+ Plan changes update the key budget, revoke ends local use and then deletes the
71
+ key (failures are owed and retried by reconcile), and the tenant project is
72
+ looked up before one is created (`@happyvertical/ai` >= 0.111.7). `./gate`'s
73
+ `registerHostedInferenceGate({ killSwitch, spend })` is core's
74
+ `InferenceGate`: kill switch `inference.hosted.enabled` (a global DISABLE
75
+ always wins), tenant, entitlement, then plan spend caps; a block throws
76
+ `InferenceUnavailableError` (`entitlement`) and writes a `blocked` usage row.
77
+ Details: [agents/hosted.md](agents/hosted.md).
78
+
79
+ ## Config
80
+
81
+ ```ts
82
+ config({ inference: {
83
+ aliases: { frontier: { candidates: [
84
+ { provider: 'anthropic', model: 'claude-…' }, // provider key var
85
+ { provider: 'openai', model: 'gpt-…', baseUrl, apiKeyEnv }, // own key + endpoint
86
+ { preset: 'lemonade', model: 'qwen…' }, // #4674 preset
87
+ { alias: 'local' }, // expanded in place
88
+ ] } },
89
+ tiers: { great: { alias: 'frontier' }, cheap: { candidates: [ … ] } },
90
+ degrade: true, // default
91
+ } });
92
+ ```
93
+
94
+ A candidate is exactly one of provider/model, preset/model, or alias.
95
+ `apiKeyEnv` is read at call time and needs a `baseUrl` (core's pair rule: a
96
+ key only travels with its own endpoint); a named variable that is unset skips
97
+ the candidate as `credential`. `findInferenceConfigIssues()` /
98
+ `validateInferenceConfig()` name every offender by path: empty alias, unknown
99
+ alias (tier or nested), cycle, key without endpoint, unknown preset. The
100
+ executor also validates every resolved target it runs (a resolver's chains
101
+ never pass through config), and a candidate key is never dropped: without
102
+ its own `baseUrl` core's pair rule refuses it.
103
+
104
+ ## Executor (`src/executor.ts`)
105
+
106
+ - Free of SDK imports: it drives the clients core builds through
107
+ `context.clientFor(target)`; tests use fake clients.
108
+ - Failure classes (`classify.ts`): `quota | credential | missing-model |
109
+ transient | refusal | missing-capability`, from error class names, `code`, `retryable` and HTTP
110
+ status across `cause` / `error` / `context.originalError`, never message
111
+ text. The SDK maps 5xx and other HTTP failures to `API_ERROR` /
112
+ `UNKNOWN_ERROR` without a status or cause (happyvertical/sdk#1493); those
113
+ count as `transient`. A definitive client error that survives in the chain
114
+ (other 4xx, `invalid_request_error`, context length) is not a routing
115
+ failure. Core's typed bind refusal (`INFERENCE_TARGET_UNBOUND`: pair rule,
116
+ `HAVE_AI_*`) is `credential` and advances. Aborts and unclassified errors stop the walk and are rethrown (an
117
+ unclassified one is still recorded).
118
+ - `transient` retries the same candidate once after 200–500 ms of jitter;
119
+ every class then advances. A `refusal` finish reason with nothing streamed
120
+ also advances; finish callbacks are replayed only for the kept answer.
121
+ - Streamed text is never extended: a candidate that emitted text and then
122
+ failed ends the walk with a `partial` substitution and
123
+ `InferenceExhaustedError` (`reason: 'partial'`, `partialText`), unless
124
+ `allowPartial` returns the text. A hosted-gate block (`AI_UNAVAILABLE`
125
+ from `clientFor()`, #4760) stops the walk and records nothing (core wrote
126
+ the `blocked` row). A step whose target names a `source` (a ladder step's
127
+ resolved binding) records that source over the chain's, so hosted spend
128
+ meters as hosted; `expandTarget()` hands a chain's own `source` to its
129
+ candidate steps, so a hosted chain reached on a ladder step is gated in
130
+ `clientFor()` and meters as hosted too. A `stream()` consumer that stops reading
131
+ aborts the provider stream (internal signal merged with the caller's).
132
+ - Ladder: when a tier's chain is spent and `degrade !== false`, resolve the
133
+ next lower tier through `context.resolveTier()`, stopping below
134
+ `minimumTier`. Never up. Steps already tried (same identity) are skipped,
135
+ so unbound lower tiers try the configured client once.
136
+ - Tags on the answering call: caller tags, `tierRequested`, `tierResolved`
137
+ (the tier that answered), and `substitutions` (JSON array of
138
+ `{ from, to, reason }`, labels `provider/model`, `preset:<id>/model`,
139
+ `default`). A call that never answered records one zero-token row through
140
+ `context.recordUsage()` (core's usage handlers; no second writer) with
141
+ `outcome: failed | partial`, `reason` and the full trail ending `to: null` (reason `unclassified` when a non-routing error ended it),
142
+ then throws `InferenceExhaustedError` (`INFERENCE_EXHAUSTED`).
143
+ - Capabilities (#4758): before calling a candidate, a call that sends
144
+ `responseFormat` (JSON) or `tools` checks `context.capabilitiesFor(target)`
145
+ (core's detection, [inference.md](../core/agents/inference.md)). A
146
+ mismatch the caller's `capabilityFallback` option permits (`json: 'text'`,
147
+ `tools: 'drop'`) runs the candidate without the feature, tagged
148
+ `capabilityFallback`; any other is a `missing-capability` substitution and
149
+ the walk advances with no model call. The option never reaches a provider.
150
+ Core's `InferenceUnavailableError` (`reason: 'capability'`) also classifies
151
+ as `missing-capability`. No detector (older core) or no requirement: no
152
+ check. Context limits are core's (single targets only).
153
+ - Add a class to `INFERENCE_FAILURE_CLASSES` and a kind to `EXECUTOR_KINDS`
154
+ (`createChainExecutor()` in `fusion.ts` dispatches on it); consumers
155
+ ignore kinds they do not run.
156
+
157
+ ## Fusions (#4753)
158
+
159
+ `{ fusion: '<key>' }` tier targets run consumer-defined fusions (`single` 1
160
+ call, `pair` 3, `handoff` 2) from, highest first, the resolver's stored app
161
+ rows (core's `lookupFusion()`, #4800), `inference.fusions`, or
162
+ `defineFusion()`; every source is re-validated before it runs. SMRT ships the interpreter, never a fusion. Seats are
163
+ smrt-prompts keys run through `createChainClient()`; a dead seat never
164
+ aborts a `pair`, all seats dead throws `InferenceFusionError`; each run writes
165
+ one `_smrt_ai_runs` row. Fusions are defined at app scope only; a tenant
166
+ may bind a tier to an allow-listed fusion, never define one. Details:
167
+ [agents/fusions.md](agents/fusions.md).
168
+
169
+ ## Doctor (`src/node.ts`)
170
+
171
+ Per tier, alias and fusion (structure only): provider recognized (AI SDK provider list or preset id),
172
+ key variable present (a provider's default variable only warns: a
173
+ same-provider target inherits the configured key), model named (warns),
174
+ alias resolvable, cycle-free. `probe` GETs `/models` (OpenAI-compatible,
175
+ Anthropic, Gemini) or Ollama `/api/tags` with a 3 s timeout, sending the key
176
+ only to its own endpoint. A binding errors when its config is invalid or no
177
+ candidate can answer; one bad candidate in a working chain warns. Keys never
178
+ appear in the report. With `probe` each candidate also reports its
179
+ capabilities and their source (#4758, `formatInferenceCapabilities()`): the
180
+ listing's context length, `supported_parameters` and input modalities
181
+ (`inferenceCapabilitiesFromModelListing()`) are the `probe` layer; the SDK
182
+ `provider` layer only through the `sdkCapabilities` option (the doctor builds
183
+ no SDK clients).
184
+
185
+ ## Settings UI and binding storage (#4754)
186
+
187
+ `./svelte` `InferenceSettingsPanel`, `./server` `InferenceSettingsService` /
188
+ `InferenceOverrideService` / `createInferenceOverrideResolver()`, `./models`
189
+ the `_smrt_inference_overrides` model (ships through `smrt db:migrate`).
190
+ Keys go to smrt-secrets through the host and are bound to their endpoint's
191
+ origin; user choices are the smrt-preferences kind `inference`; app/tenant
192
+ bindings are override rows, tenants limited to the app row's allow-list and
193
+ the `tenantTargetPolicy` hook. The app's transcript setting (#4781) is the
194
+ app-only `transcripts` row, editable in the panel only with `appEditable`,
195
+ applied as core's transcript app settings layer; the Usage tab shows
196
+ transcript counts and links to a host route, never content. Details: [agents/settings.md](agents/settings.md),
197
+ consumer guide [docs/content/inference-settings.md](../../docs/content/inference-settings.md).
198
+
199
+ ## Validation
200
+
201
+ ```bash
202
+ pnpm --filter @happyvertical/smrt-inference test
203
+ pnpm --filter @happyvertical/smrt-inference test:postgres # hosted provisioning on PostgreSQL (advisory lock, runOnce)
204
+ pnpm --filter @happyvertical/smrt-inference typecheck
205
+ pnpm --filter @happyvertical/smrt-inference build
206
+ ```
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,105 @@
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, highest first (each replaces a lower one whole):
6
+ the registered resolver's stored layer through core's
7
+ `context.lookupFusion(key)` (#4800; the override resolver's app rows),
8
+ `inference.fusions.<key>` (app config), or
9
+ `defineFusion({ key, mode, seats, gate?, description? })` (process registry
10
+ on `globalThis`). A seat is `{ name?, prompt, target, temperature?, output? }`:
11
+ `prompt` is a smrt-prompts key whose resolved template (code default,
12
+ config, app and tenant overrides, read through `context.db`) is the seat's
13
+ system message, so tenant overrides of a seat need nothing new; `target` is
14
+ a tier (resolved through the same resolver with the seat's prompt key, so
15
+ per-user bindings apply), `{ alias }`, `{ candidates }` or an inline
16
+ `{ provider, model }`, never a fusion. Each seat runs through
17
+ `createChainClient()`, so chains, classified fallback and (tier seats only)
18
+ the ladder apply per seat. `name` defaults to the prompt key and must be
19
+ unique.
20
+
21
+ Example (a consumer's code; SMRT does not ship it):
22
+
23
+ ```ts
24
+ definePrompt({ key: 'contracts.skeptic', template: 'List the risks…' });
25
+ definePrompt({ key: 'contracts.advocate', template: 'List the benefits…' });
26
+ definePrompt({ key: 'contracts.synthesis', template: 'Weigh both reviews…' });
27
+ config({ inference: {
28
+ tiers: { best: { fusion: 'contract-review' } },
29
+ fusions: { 'contract-review': {
30
+ mode: 'pair',
31
+ seats: [
32
+ { name: 'skeptic', prompt: 'contracts.skeptic', target: 'good' },
33
+ { name: 'advocate', prompt: 'contracts.advocate', target: { alias: 'frontier' } },
34
+ { name: 'synthesis', prompt: 'contracts.synthesis', target: 'great' },
35
+ ],
36
+ gate: { criteria: 'Both reviews agree on every material risk.',
37
+ sufficientWhen: { choice: 'agree', minConfidence: 0.85 } },
38
+ } },
39
+ } });
40
+ await contract.do('Summarize this contract', { tier: 'best' }); // unchanged caller
41
+ ```
42
+
43
+ - **Modes and call counts** (model calls; a seat's fallback candidates are
44
+ substitutions of that seat, not extra seats): `single` 1 (one seat);
45
+ `pair` 3 (seats 0 and 1 in parallel, then seat 2 merges both outputs,
46
+ labelled by seat and model); `handoff` 2 (seat 0 drafts, seat 1 receives
47
+ the draft and answers). A mode that adds a call is a bug.
48
+ - **Gate** (`pair`, `handoff`): one choice question through
49
+ `context.decide()` (core's typed-decision client, the `evaluate()` path)
50
+ over the outputs so far, labels `sufficientWhen.choice` and `continue`.
51
+ The chosen label at `minConfidence` or above skips the remaining stage,
52
+ recorded `skipped`; the answer is then seat 0's output (`pair`: the first
53
+ live one) or the draft. No decision client (`unavailable`) or a decision
54
+ error runs the stage. Its usage row has `seat: 'gate'`, a reserved seat
55
+ name.
56
+ - **Failure semantics.** A dead seat never aborts a `pair`: the merge seat
57
+ is told which seat failed and why (class and message). A dead merge or
58
+ handoff answer seat falls back to the first live output, recorded
59
+ `degraded`; the handoff answer seat is told when the draft died. A run
60
+ whose seats all died throws `InferenceFusionError`
61
+ (`INFERENCE_FUSION_FAILED`, `result` holds every seat) with the run
62
+ recorded `failed`, never an empty answer. A seat whose setup fails (prompt
63
+ override read, its tier's resolver) dies like a model failure. A config
64
+ error, a caller abort, or a final seat that already streamed text
65
+ (`partial`) rethrows, after both `pair` seats have settled.
66
+ - **JSON**: `output: 'json'` sends `responseFormat: json_object` and parses;
67
+ a seat whose text does not parse keeps it under `raw` (with `parseError`)
68
+ in `FusionSeatResult` and hands the raw text on, labelled. A candidate
69
+ without JSON mode runs the seat in text on that recovery path
70
+ (`capabilityFallback: { json: 'text' }`, #4758) instead of being skipped.
71
+ - **Caller options**: generation options (`responseFormat`, `tools`,
72
+ `onProgress`, …) reach the final seat only; `signal` / `timeout` reach every
73
+ seat; `history` is the seat's instructions. `onFusionResult(result)`
74
+ receives the structured `FusionRunResult`. Only `message()` runs a fusion.
75
+ - **Record**: every seat's usage row carries `runId`, `fusionKey`, `seat`
76
+ and the seat's prompt tags; `context.recordRun()` writes one
77
+ `_smrt_ai_runs` row (`ok | degraded | failed`; a seat is degraded when it
78
+ died or answered through a substitution) with identities and totals only
79
+ (`../../core/agents/ai-usage.md`). `smrt ai:usage --by fusion` totals runs.
80
+ - **Validation**: `findInferenceConfigIssues()` names structural offenders
81
+ by path (`fusion-invalid`, `fusion-unknown`, `fusion-nested`); an
82
+ unregistered seat prompt key is a load error (`fusion-prompt-unknown`) from
83
+ `validateInferenceConfig()`, the resolver, the executor and
84
+ `defineFusion()`, but not the doctor (it runs without the app's prompts).
85
+ - Seat prompts resolve through `@happyvertical/smrt-prompts` (a dependency;
86
+ never a peer, #1582), imported by a variable specifier from this package
87
+ (strict layouts find it; bundlers do not follow it, so the root stays browser-safe),
88
+ then core's `importOptionalDependency()` (a module registered with
89
+ `registerOptionalDependency()` in bundled builds).
90
+ `setFusionPromptResolver()` replaces it.
91
+ - **Fusions are app-scope only.** A fusion multiplies hosted model spend
92
+ per call, so a tenant never defines or redefines one; with #4754's
93
+ allow-list a tenant may bind a tier to a listed fusion (`fusion:<n>`).
94
+ - **Stored fusions (#4800).** Core hands the executor
95
+ `context.lookupFusion(key)`: the instance's (or registered) resolver's
96
+ optional `lookupFusion(key, request, context)`, then the merged
97
+ `inference.fusions`; `undefined` falls to the registry. Without it (older
98
+ cores) the executor reads config, then the registry, and validates at bind
99
+ time; with it the lookup starts at bind time and an unknown or invalid
100
+ fusion fails the first call before any seat runs (nothing recorded). Every
101
+ definition, whatever its source, is validated before it runs (structure
102
+ against the call's aliases, every seat prompt key); a resolver's answer
103
+ fails under path `resolver.fusions.<key>`. The #4752 validating wrapper
104
+ and the #4756 user resolver forward `lookupFusion` to their base /
105
+ `fallback` (stored user and tenant configs never define a fusion).
@@ -0,0 +1,161 @@
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` >= 0.111.7
15
+ (happyvertical/sdk#1494); never reimplement gateway administration here.
16
+ `HostedGatewayAdmin` is `HOSTED_GATEWAY_ADMIN_METHODS` (`createProject`,
17
+ `createVirtualKey`, `updateVirtualKey`, `deleteVirtualKey`,
18
+ `listProjects`); a provider missing one is `HOSTED_GATEWAY_UNSUPPORTED`.
19
+ SMRT persists only `AIVirtualKey.id`, the non-secret handle (Bifrost id,
20
+ LiteLLM `token_id`); an id that equals or looks like a key value (`sk-…`)
21
+ is dropped on create and when older state is read, so that key counts as
22
+ having no id. Budget sync sends
23
+ `updateVirtualKey(id, { budget })` only (Bifrost refuses `models` /
24
+ `metadata`, LiteLLM `providerConfigs`: `ADMIN_UNSUPPORTED_FIELD`);
25
+ `unsupported` now means the grant has no key id. Revoke ends local use
26
+ before touching the gateway (config restored, state retired with the key
27
+ owed, then `deleteVirtualKey`; `false` is `missing`, an already revoked
28
+ key, nothing owed). A deletion the gateway did not perform (`failed`, a
29
+ key a failed provision left, `unsupported`: no admin configured or no key
30
+ id; entries from before 0.111.7 also read `unsupported`) goes to the
31
+ state's `pendingGatewayRevocations` (id + alias, never the key), is
32
+ audited with `manualGatewayRevocation`, and is retried by `revoke()` /
33
+ `reconcileTenant()`; each retry that settles one writes
34
+ `inference.hosted.revoke-retry` with `settled: [{ keyId, keyAlias,
35
+ gateway }]`, closing those records. Entries without an id stay for an
36
+ operator (`acknowledgeGatewayRevocation`).
37
+ - **Config, read once from the environment** (`getHostedGatewayConfig()`;
38
+ unset provider = hosted off): `SMRT_INFERENCE_HOSTED_PROVIDER`
39
+ (`bifrost|litellm`), `_BASE_URL` (the OpenAI-compatible endpoint keys are
40
+ used against), `_ADMIN_URL`, `_ADMIN_KEY` (LiteLLM master key, required for
41
+ litellm; or a Bifrost admin key), `_ADMIN_USER` / `_ADMIN_PASSWORD`
42
+ (Bifrost Basic auth), `_MODEL`, `_<TIER>_MODEL`, `_MODELS` (comma list the
43
+ key may use; default the binding's models), `_KEY_DURATION`,
44
+ `_ADMIN_TIMEOUT_MS` (deadline per admin call, default 30 s, also passed as
45
+ the SDK `timeout`; a call past it is a failure, so a stalled gateway never
46
+ holds the lifecycle lock). A create that fails ambiguously (timeout,
47
+ transport reset, 5xx, unclassified; anything but a credential, quota,
48
+ missing-model or refusal answer per `classifyInferenceFailure`) records
49
+ its alias as owed, since the gateway may have minted the key.
50
+ Errors name missing fields, never values.
51
+ - **Storage** (smrt-secrets, `context = tenantId`): the key lives only in
52
+ the user's `inference.provider-config:user:<id>` (`source: 'hosted'`,
53
+ `mode: 'hosted'`, `gatewayKeyAlias`, a binding per configured tier);
54
+ `inference.hosted:user:<id>` (category `inference.hosted`) holds status,
55
+ generation, key id and alias, budget and the user's prior config;
56
+ `inference.hosted:project` the tenant's gateway project (created once
57
+ through `runOnce()`, name `smrt tenant <tenantId>`, LiteLLM id
58
+ `smrt-<tenantId>-<nonce>`). Without a stored record, `listProjects({
59
+ tenantId })` runs first and adopts the lowest-id project with that name
60
+ (and id prefix on LiteLLM), audited `adopted: true`, so a crash between
61
+ create and persist never makes a second project; a failed lookup fails
62
+ the provision rather than create blind. Every write passes
63
+ `auditDetails.operation` (`inference.hosted.provision|revoke|budget|project`)
64
+ to `SecretAuditLog`.
65
+ - **Idempotency and ordering.** An active grant whose binding is still the
66
+ stored one is returned (`created: false`), its budget synced when it
67
+ differs. The create path runs under `runOnce()` keyed by `(tenantId,
68
+ userId, generation)`; revoke advances the generation. Aliases
69
+ (`smrt-<tenant>-<user>-g<n>-<nonce>`) and LiteLLM project ids
70
+ (`smrt-<tenant>-<nonce>`) are unique per attempt, so a failed attempt's
71
+ leftovers never block the retry. Lifecycle operations run one at a time
72
+ per process (a process-wide chain: rare operations, and it bounds the
73
+ pooled connections held) and, on PostgreSQL, under a per-tenant
74
+ `pg_advisory_xact_lock` held in a transaction for the whole operation
75
+ (its writes use other pooled connections: at most three per process), so
76
+ a tenant-wide revoke or reconcile never misses an in-flight provision.
77
+ The optional `entitled(tenantId)` check runs inside that lock before a
78
+ key is minted (`HOSTED_NOT_ENTITLED`) and before an entitled reconcile
79
+ acts (a stale snapshot then revokes), so a stale gain or late retry after
80
+ a downgrade does not activate. Hosts running the listener or reconciler
81
+ across processes should set it. A failed binding write deletes or records the new
82
+ key and restores the previous state after the claim rolls back (no delete
83
+ inside the claim transaction: single-connection adapters would wait on
84
+ it). A user who replaced the hosted binding keeps their config: an
85
+ explicit `provision()` retires the stale key and mints anew; a reconcile
86
+ only retires it and marks the state `optedOut`, which later reconciles
87
+ respect until an explicit `provision()`. Writes through the #4756
88
+ resolver (the settings panel) do not take this lock.
89
+ - **Revoke** restores the prior config when it holds a binding or a mode,
90
+ else writes `{ mode: 'off' }`; a config the user replaced is kept.
91
+ - **Budget** is integer micro-dollars (`{ limitMicros, window? }`, `day |
92
+ week | month | year`, default month), converted to the gateway's USD
93
+ `maxLimit` and `1d|7d|30d|365d` only at the admin call. #4760's spend gate
94
+ is the authoritative pre-call check; the gateway budget is defense in depth.
95
+ An unchanged budget is never re-sent (a gateway update may restart the
96
+ spend window); after a failed or timed-out update (`budgetSyncUncertainAt`,
97
+ it may still land) syncs re-apply the stored budget until a confirmed
98
+ update 15 minutes later.
99
+ - **Entitlement hook.** `createHostedInferenceEntitlementListener({
100
+ provisioner, users, budgetFor?, defaultBudget? })` structurally matches
101
+ smrt-subscriptions' `EntitlementChangeListener` (register it with
102
+ `registerEntitlementChangeListener()`; the host wraps its webhook write in
103
+ `trackEntitlementChange()`). A change touching `inference.hosted`
104
+ reconciles the tenant to the change's `after` snapshot through
105
+ `createHostedInferenceReconciler()`: entitled provisions `users(tenantId)`
106
+ (membership is the host's; exclude users an operator revoked) unless
107
+ they opted out, and syncs budgets; not entitled revokes every grant; both
108
+ retry owed deletions (also for active grants: they are old keys). The
109
+ plan's budget is the grant's `metadata.budget` (`{ limitMicros, window }`).
110
+ A replayed webhook diffs to "no change", so hosts also call the reconciler
111
+ with `snapshotEntitlements()` on redelivery and on a schedule; that is
112
+ what repairs a reaction lost to a crash. Per-user failures are thrown
113
+ together after all users were tried.
114
+ - **Keys.** Never in results, errors (gateway failures carry only the
115
+ operation and error code), audit details, `runOnce()` results or logs.
116
+ Usage rows carry `source: hosted` and `gatewayKeyAlias` (from
117
+ `InferenceTarget.usageTags`). Other processes see a new binding after the
118
+ resolver cache expires; this process is invalidated through
119
+ `invalidateUserInferenceResolvers()`.
120
+
121
+ ## Spend gate and kill switch (`src/gate.ts`, #4760)
122
+
123
+ `createHostedInferenceGate({ killSwitch, spend, principal?,
124
+ requireSubscription?, onWarning? })` is core's `InferenceGate` (seam:
125
+ [core inference.md](../../core/agents/inference.md), "Hosted gate");
126
+ `registerHostedInferenceGate(gate | options)` installs it and the
127
+ principal's user as the `userId` usage tag. Host wiring:
128
+
129
+ ```ts
130
+ registerHostedInferenceGate({
131
+ killSwitch: new KillSwitchService({ db }), // smrt-features
132
+ spend: await HostedAiSpendEvaluator.create({ db }), // smrt-subscriptions
133
+ });
134
+ ```
135
+
136
+ Both stores are structural: smrt-features and smrt-subscriptions build with
137
+ the CLI, which depends on this package, so a dependency on either is a turbo
138
+ cycle. Checks, in order, on every `source: hosted` call:
139
+
140
+ 1. **Kill switch** `inference.hosted.enabled` (`HOSTED_INFERENCE_KILL_SWITCH`):
141
+ global DISABLE, then the tenant's DISABLE, blocks (`policy:
142
+ 'kill-switch'`, `disabledBy`). Uncached, no model call: **the incident
143
+ lever** (`smrt inference:hosted disable [--tenant <id>]`; programmatic
144
+ `setHostedInferenceEnabled(store, { enabled, tenantId? })`,
145
+ `getHostedInferenceSwitch()`). Stored as smrt-features override rows
146
+ with kill-switch semantics, not as a flag: a tenant ENABLE can never
147
+ lift a global DISABLE, and `inference.hosted` (the plan key) stays a pure
148
+ entitlement. Hosts with an admin UI write the same DISABLE rows through
149
+ `FeatureOverrideService` and their authorizer.
150
+ 2. **Tenant**: request tenant, else the principal's; none blocks (`no-tenant`).
151
+ 3. **Entitlement**: no entitled subscription blocks (`subscription`) unless
152
+ `requireSubscription: false`.
153
+ 4. **Spend**: the plan's tenant and per-user caps (smrt-subscriptions
154
+ AGENTS, "Hosted AI spend caps"): `blocked` blocks with `cap`, `spend`
155
+ (micro-dollars) and `resetsAt`; `warn` returns `warn` and calls
156
+ `onWarning` (errors swallowed). The user is the principal's, only in its
157
+ own tenant, and none for `userId: null` (agents).
158
+
159
+ Any thrown error propagates and core blocks with `gate-error`. The plan
160
+ check does not test the `inference.hosted` feature key: provisioning the
161
+ 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)).