@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.
- package/AGENTS.md +206 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +73 -2
- package/agents/fusions.md +105 -0
- package/agents/hosted.md +161 -0
- package/agents/resolver.md +65 -0
- package/agents/settings.md +107 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/chunks/InferenceOverrideCollection-Dat4TrK5.js +423 -0
- package/dist/chunks/InferenceOverrideCollection-Dat4TrK5.js.map +1 -0
- package/dist/chunks/provider-config-BSpIQRVM.js +1873 -0
- package/dist/chunks/provider-config-BSpIQRVM.js.map +1 -0
- package/dist/classify.d.ts +32 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/executor.d.ts +45 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/fusion-store.d.ts +16 -0
- package/dist/fusion-store.d.ts.map +1 -0
- package/dist/fusion.d.ts +155 -0
- package/dist/fusion.d.ts.map +1 -0
- package/dist/gate.d.ts +143 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +94 -0
- package/dist/gate.js.map +1 -0
- package/dist/hosted.d.ts +461 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +453 -0
- package/dist/models/InferenceOverride.d.ts +38 -0
- package/dist/models/InferenceOverride.d.ts.map +1 -0
- package/dist/models/InferenceOverrideCollection.d.ts +35 -0
- package/dist/models/InferenceOverrideCollection.d.ts.map +1 -0
- package/dist/models.d.ts +11 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +2 -0
- package/dist/node.d.ts +110 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +1414 -0
- package/dist/node.js.map +1 -0
- package/dist/provider-config.d.ts +113 -0
- package/dist/provider-config.d.ts.map +1 -0
- package/dist/register.d.ts +30 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/resolver.d.ts +166 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js +291 -0
- package/dist/resolver.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1138 -0
- package/dist/server.js.map +1 -0
- package/dist/settings/cache.d.ts +5 -0
- package/dist/settings/cache.d.ts.map +1 -0
- package/dist/settings/keys.d.ts +70 -0
- package/dist/settings/keys.d.ts.map +1 -0
- package/dist/settings/layers.d.ts +68 -0
- package/dist/settings/layers.d.ts.map +1 -0
- package/dist/settings/override-service.d.ts +119 -0
- package/dist/settings/override-service.d.ts.map +1 -0
- package/dist/settings/preferences.d.ts +137 -0
- package/dist/settings/preferences.d.ts.map +1 -0
- package/dist/settings/resolver.d.ts +41 -0
- package/dist/settings/resolver.d.ts.map +1 -0
- package/dist/settings/settings-service.d.ts +355 -0
- package/dist/settings/settings-service.d.ts.map +1 -0
- package/dist/settings/targets.d.ts +112 -0
- package/dist/settings/targets.d.ts.map +1 -0
- package/dist/settings/transcripts.d.ts +33 -0
- package/dist/settings/transcripts.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +341 -0
- package/dist/smrt-knowledge.okf.md +80 -0
- package/dist/svelte/components/InferenceAliasEditor.svelte +210 -0
- package/dist/svelte/components/InferenceAliasEditor.svelte.d.ts +13 -0
- package/dist/svelte/components/InferenceAliasEditor.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InferenceDefaultView.svelte +330 -0
- package/dist/svelte/components/InferenceDefaultView.svelte.d.ts +15 -0
- package/dist/svelte/components/InferenceDefaultView.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InferenceFusionList.svelte +141 -0
- package/dist/svelte/components/InferenceFusionList.svelte.d.ts +16 -0
- package/dist/svelte/components/InferenceFusionList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InferenceSettingsPanel.svelte +392 -0
- package/dist/svelte/components/InferenceSettingsPanel.svelte.d.ts +84 -0
- package/dist/svelte/components/InferenceSettingsPanel.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InferenceTierRow.svelte +199 -0
- package/dist/svelte/components/InferenceTierRow.svelte.d.ts +14 -0
- package/dist/svelte/components/InferenceTierRow.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InferenceTranscriptSettings.svelte +165 -0
- package/dist/svelte/components/InferenceTranscriptSettings.svelte.d.ts +18 -0
- package/dist/svelte/components/InferenceTranscriptSettings.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InferenceUsageTable.svelte +177 -0
- package/dist/svelte/components/InferenceUsageTable.svelte.d.ts +21 -0
- package/dist/svelte/components/InferenceUsageTable.svelte.d.ts.map +1 -0
- package/dist/svelte/index.d.ts +17 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +14 -0
- package/dist/svelte/types.d.ts +262 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +124 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- 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
|
-
#
|
|
1
|
+
# @happyvertical/smrt-inference
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/agents/hosted.md
ADDED
|
@@ -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)).
|