@lenne.tech/nest-server 11.32.2 → 11.32.3

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.
@@ -0,0 +1,129 @@
1
+ # Migration Guide: 11.32.2 → 11.32.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `ai.capabilityDriftCheck` — opt-in boot warning when an explicit connection capability contradicts the endpoint; `ai.defaultConnection.contextWindow` — seed a connection's context window from config |
9
+ | **Bugfixes** | The AI connection `contextWindow` admin input now rejects non-positive / non-integer values |
10
+ | **Migration Effort** | ~2 minutes — everything is opt-in; read §3 only if your admin tooling writes `contextWindow` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.32.3
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ No configuration change is required. Everything below is opt-in or a
23
+ tightening you inherit automatically.
24
+
25
+ ---
26
+
27
+ ## 1. New: `ai.capabilityDriftCheck` (opt-in, default `false`)
28
+
29
+ A connection's `supportsNativeTools` / `supportsJsonResponse` flags are
30
+ auto-detected while they are left **undefined**, but an **explicit** value is
31
+ authoritative and is never re-probed. A wrong explicit flag therefore degrades the
32
+ assistant silently — e.g. `supportsNativeTools: false` on a backend that *does*
33
+ support native function calling forces fragile emulated tool-calling.
34
+
35
+ Enable this opt-in boot self-check to surface such a mismatch:
36
+
37
+ ```typescript
38
+ ai: {
39
+ capabilityDriftCheck: true, // default false
40
+ }
41
+ ```
42
+
43
+ When enabled, at startup the module probes each enabled connection that declares an
44
+ explicit flag and logs a **warning** on mismatch. It never changes the stored value
45
+ (your explicit choice stays authoritative — clear the flag in the admin UI to
46
+ re-enable auto-detection).
47
+
48
+ **It is off by default because it makes outbound calls to the LLM endpoints on
49
+ every boot** (and is additionally skipped in the `ci`/`e2e` runners). Note this is a
50
+ diagnostic log only: if you deliberately override a flag against what the endpoint
51
+ reports (e.g. native tools are advertised but unreliable on your model), the warning
52
+ is expected and can be ignored.
53
+
54
+ **Action: none** unless you want the diagnostic — then set the flag.
55
+
56
+ ---
57
+
58
+ ## 2. New: `ai.defaultConnection.contextWindow`
59
+
60
+ The one-time `ai.defaultConnection` seed now accepts `contextWindow`, so a
61
+ config-seeded connection can carry its context window from the start instead of
62
+ relying on auto-detection:
63
+
64
+ ```typescript
65
+ ai: {
66
+ defaultConnection: {
67
+ name: 'default',
68
+ baseUrl: '...',
69
+ model: '...',
70
+ contextWindow: 32768, // optional — omit to auto-detect
71
+ },
72
+ }
73
+ ```
74
+
75
+ Omit it to keep the existing behaviour (auto-detect by probing the endpoint /
76
+ `knownContextWindow()`, falling back to the global `ai.contextWindow` default of
77
+ 8192).
78
+
79
+ **Action: none** — additive and optional.
80
+
81
+ ---
82
+
83
+ ## 3. Tightened: `contextWindow` admin input validation
84
+
85
+ The `contextWindow` field on the AI connection create/update input is now validated
86
+ as a **positive integer** (`@IsInt` + `@Min(1)`). Previously any number (including
87
+ `0`, a negative, or a float) was accepted verbatim, and an explicit `0` is not
88
+ "unset" — it would have fed the orchestrator's context budget as a real (broken)
89
+ value instead of falling back to the safe default.
90
+
91
+ **Action required if** your admin tooling or tests send a non-integer or
92
+ `< 1` `contextWindow` to `createAiConnection` / `updateAiConnection` — those calls
93
+ now return a validation error. Send a positive integer, or omit the field to
94
+ auto-detect.
95
+
96
+ ---
97
+
98
+ ## 4. Documentation: the MCP confirmation-gate boundary
99
+
100
+ No code change — a clarification of the `IAiTool.destructive` / `.mutating` JSDoc
101
+ (reinforcing §5 of the 11.32.2 guide). Over `/ai/mcp` there is **no confirmation
102
+ gate**: `mcpCallTool` consults neither flag, so a destructive tool executes on the
103
+ first call. The barriers that *do* hold on every path are the registry **role
104
+ filter** (`forUser()`, applied before `execute()`) and the authorization inside
105
+ `execute()` itself — so a destructive tool restricted to a real role stays
106
+ unreachable by lesser-privileged MCP clients; MCP only skips the extra confirmation
107
+ step for clients that may already see the tool.
108
+
109
+ **Action:** expose `/ai/mcp` only to clients you trust to obtain user consent
110
+ themselves, and keep data-level authorization inside `execute()` (not only in the
111
+ plan-mode `authorize()`).
112
+
113
+ ---
114
+
115
+ ## Module Documentation
116
+
117
+ - [AI module README](../src/core/modules/ai/README.md) — see "Capability auto-detection"
118
+ - [AI INTEGRATION-CHECKLIST](../src/core/modules/ai/INTEGRATION-CHECKLIST.md) — advanced configuration
119
+ - [Configurable features](../.claude/rules/configurable-features.md) — the AI Assistant row
120
+
121
+ ---
122
+
123
+ ## Troubleshooting
124
+
125
+ | Symptom | Cause | Fix |
126
+ |---------|-------|-----|
127
+ | Boot logs "capability drift" for a connection | §1 — an explicit flag disagrees with the endpoint | Correct the flag in the admin UI, or clear it to auto-detect; ignore if the override is deliberate |
128
+ | `createAiConnection` rejects `contextWindow` | §3 — it must be a positive integer now | Send an integer `>= 1`, or omit to auto-detect |
129
+ | A destructive tool ran over `/ai/mcp` without confirmation | §4 — MCP has no confirmation gate by design | Only expose MCP to trusted clients; enforce data-level checks in `execute()` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.32.2",
3
+ "version": "11.32.3",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -1110,6 +1110,16 @@ export interface IAiDefaultConnection {
1110
1110
  /** Capability tags (free-form, e.g. 'analysis', 'vision'). */
1111
1111
  capabilities?: string[];
1112
1112
 
1113
+ /**
1114
+ * Total context window (input + output tokens) the model supports. Drives the
1115
+ * orchestrator's context budget (system prompt + history + tool results). Omit to
1116
+ * auto-detect by probing the endpoint / `knownContextWindow()`; set it explicitly
1117
+ * when the endpoint exposes no limit and the model id is unknown to the heuristic
1118
+ * (otherwise the orchestrator assumes the conservative `ai.contextWindow` default
1119
+ * of 8192 and trims the prompt + tool results on every turn).
1120
+ */
1121
+ contextWindow?: number;
1122
+
1113
1123
  /** Default maximum number of tokens for completions. */
1114
1124
  defaultMaxTokens?: number;
1115
1125
 
@@ -1212,6 +1222,19 @@ export interface IAi {
1212
1222
  user?: { maxPrompts?: number; maxTokens?: number };
1213
1223
  };
1214
1224
 
1225
+ /**
1226
+ * Opt-in boot self-check: after startup, probe each enabled connection that declares
1227
+ * an EXPLICIT `supportsNativeTools` / `supportsJsonResponse` and warn (log only) when
1228
+ * the declared value contradicts what the endpoint actually reports — a wrong explicit
1229
+ * flag otherwise silently degrades the assistant (e.g. forcing fragile emulated
1230
+ * tool-calling on a backend that supports native function calling). OFF by default
1231
+ * because it makes outbound calls to the LLM endpoints on every boot; the declared
1232
+ * value is never changed (clear it in the admin UI to re-enable auto-detection). Also
1233
+ * skipped in the ci/e2e runners.
1234
+ * @default false
1235
+ */
1236
+ capabilityDriftCheck?: boolean;
1237
+
1215
1238
  /**
1216
1239
  * Confirmation policy for mutating tool actions (create/update/delete).
1217
1240
  * `destructive` tools always require confirmation regardless of this policy.
@@ -111,6 +111,12 @@ never probed). Detection runs in two complementary ways:
111
111
  once, persists, and uses the result. Until then the safe emulated baseline applies.
112
112
  - **On demand:** admins can re-probe via `detectAiConnectionCapabilities` /
113
113
  `POST /ai/connections/:id/detect-capabilities` (e.g. after changing `baseUrl`/`model`).
114
+ - **Boot drift check (opt-in):** set `ai.capabilityDriftCheck: true` to probe every enabled
115
+ connection that has an EXPLICIT flag once at startup and log a warning when the declared
116
+ value contradicts what the endpoint reports (a wrong explicit flag otherwise degrades the
117
+ assistant silently, e.g. forcing emulated tool-calling on a native-capable backend). It only
118
+ warns — the stored value is never changed. OFF by default because it makes outbound calls to
119
+ the LLM endpoints on every boot; also skipped in the ci/e2e runners.
114
120
 
115
121
  The probe is provider-agnostic best effort: `response_format: json_object` is sent
116
122
  (2xx → JSON supported); a trivial tool with `tool_choice: 'required'` is sent (2xx
@@ -1,4 +1,5 @@
1
1
  import { InputType } from '@nestjs/graphql';
2
+ import { IsInt, Min } from 'class-validator';
2
3
 
3
4
  import { Restricted } from '../../../common/decorators/restricted.decorator';
4
5
  import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
@@ -72,6 +73,7 @@ export class CoreAiConnectionInput {
72
73
  isOptional: true,
73
74
  roles: RoleEnum.ADMIN,
74
75
  type: () => Number,
76
+ validator: (options) => [IsInt(options), Min(1, options)],
75
77
  })
76
78
  contextWindow?: number = undefined;
77
79
 
@@ -69,9 +69,21 @@ export interface IAiTool {
69
69
 
70
70
  /**
71
71
  * Whether the tool performs a destructive/irreversible action (delete, bulk
72
- * update, payment, …). Destructive tools always require confirmation: they are
73
- * NOT executed until the prompt is re-sent with `confirm: true`; the first
74
- * response lists them as `pendingActions` with `requiresConfirmation: true`.
72
+ * update, payment, …). In the CHAT orchestrator destructive tools always require
73
+ * confirmation: they are NOT executed until the prompt is re-sent with
74
+ * `confirm: true`; the first response lists them as `pendingActions` with
75
+ * `requiresConfirmation: true`.
76
+ *
77
+ * **No confirmation gate over MCP.** `CoreAiMcpService.mcpCallTool` consults
78
+ * neither this flag nor {@link IAiTool.mutating}, so a destructive tool invoked
79
+ * through `/ai/mcp` executes IMMEDIATELY, on the first call. This flag is
80
+ * therefore a chat-orchestrator contract, not a global execution barrier. The
81
+ * barriers that DO hold on every path are the registry role filter ({@link
82
+ * IAiTool.roles}, applied by `forUser()` before `execute()`) and the authorization
83
+ * inside `execute()` itself — so a destructive tool restricted to a real role stays
84
+ * unreachable by lesser-privileged MCP clients; MCP only skips the extra confirmation
85
+ * step for clients that may already see the tool. Expose MCP only to clients you trust
86
+ * to obtain user consent themselves.
75
87
  */
76
88
  readonly destructive?: boolean;
77
89
 
@@ -80,6 +92,9 @@ export interface IAiTool {
80
92
  * mutating tools is governed by the `ai.confirmation` policy (admin default,
81
93
  * optionally client-overridable, optionally enforced). `destructive` is the
82
94
  * stronger flag and always requires confirmation regardless of policy.
95
+ *
96
+ * Same MCP caveat as {@link IAiTool.destructive}: the confirmation policy is not
97
+ * evaluated on the `/ai/mcp` path at all.
83
98
  */
84
99
  readonly mutating?: boolean;
85
100
 
@@ -32,6 +32,29 @@ import { AI_CONNECTION_CLASS, AI_CONNECTION_MODEL } from '../core-ai.constants';
32
32
  */
33
33
  export { AI_CONNECTION_CLASS, AI_CONNECTION_MODEL } from '../core-ai.constants';
34
34
 
35
+ /**
36
+ * Minimal shape of a persisted (lean) connection document that the boot-time drift
37
+ * check needs to build a provider for a probe. It mirrors the fields
38
+ * {@link CoreAiConnectionService.resolve} reads, declared locally so the drift check can
39
+ * use the bulk `find()` result directly (no per-connection re-read / N+1).
40
+ */
41
+ type ResolvableConnectionDoc = {
42
+ _id: unknown;
43
+ apiKeyEncrypted?: string;
44
+ apiKeyEnv?: string;
45
+ baseUrl: string;
46
+ contextWindow?: number;
47
+ defaultMaxTokens?: number;
48
+ defaultTemperature?: number;
49
+ defaultUserMaxPeriod?: string;
50
+ defaultUserMaxTokens?: number;
51
+ model: string;
52
+ name: string;
53
+ providerType?: string;
54
+ supportsJsonResponse?: boolean;
55
+ supportsNativeTools?: boolean;
56
+ };
57
+
35
58
  /**
36
59
  * CRUD service for {@link CoreAiConnection} — the database-backed LLM
37
60
  * configuration. Admin-only (enforced by the model's `@Restricted(ADMIN)` plus
@@ -75,6 +98,9 @@ export class CoreAiConnectionService
75
98
  async onModuleInit(): Promise<void> {
76
99
  await this.seedDefaultConnection();
77
100
  await this.assertStoredKeysDecryptable();
101
+ // Best-effort, non-blocking: probe endpoints and warn on capability drift. Never
102
+ // awaited so a slow/unreachable endpoint cannot delay boot.
103
+ void this.warnOnCapabilityDrift();
78
104
  }
79
105
 
80
106
  /**
@@ -142,6 +168,115 @@ export class CoreAiConnectionService
142
168
  }
143
169
  }
144
170
 
171
+ /**
172
+ * Opt-in boot self-check (`ai.capabilityDriftCheck`, default OFF): warn when a
173
+ * connection DECLARES a capability that contradicts what its endpoint actually
174
+ * reports. Capabilities are auto-detected only for flags left UNDEFINED (create +
175
+ * lazy runtime path); an EXPLICIT `supportsNativeTools` / `supportsJsonResponse` is
176
+ * authoritative and is never re-probed by the normal path — so a wrong explicit flag
177
+ * silently degrades the assistant forever (e.g. `supportsNativeTools: false` on an
178
+ * endpoint that DOES support native function calling forces fragile emulated
179
+ * tool-calling, which weaker models do not sustain once the prompt grows).
180
+ *
181
+ * To observe the endpoint's REAL capability for a DECLARED flag, it builds the provider
182
+ * with the flags cleared to `undefined` — otherwise the provider's `detectCapabilities()`,
183
+ * which probes ONLY undefined flags, would return nothing to compare against (the whole
184
+ * point of the check) — then diffs the probed booleans against the stored declaration.
185
+ *
186
+ * It NEVER changes the stored value (the admin's explicit choice stays authoritative),
187
+ * NEVER blocks boot (fire-and-forget, all errors swallowed), and issues outbound calls
188
+ * to the LLM endpoints — hence it is OFF by default and additionally skipped in the
189
+ * ci/e2e runners. It reads every enabled connection in a single query (no per-connection
190
+ * re-read). Connections that leave BOTH flags undefined are handled by
191
+ * {@link detectAndPersistCapabilities} and are skipped here (nothing declared to check).
192
+ */
193
+ protected async warnOnCapabilityDrift(): Promise<void> {
194
+ // Opt-in: a framework boot must not contact third-party endpoints unless asked.
195
+ if (!ConfigService.get<boolean>('ai.capabilityDriftCheck')) {
196
+ return;
197
+ }
198
+ // Defense in depth: never probe from the integration test runner (real module boot).
199
+ // The unit runner (NODE_ENV=test) is intentionally NOT excluded so the method stays
200
+ // unit-testable with a mocked providerFactory — the opt-in flag above already prevents
201
+ // accidental probing there.
202
+ if (!this.providerFactory || ['ci', 'e2e'].includes(process.env.NODE_ENV ?? '')) {
203
+ return;
204
+ }
205
+ try {
206
+ // Single read (no per-connection re-resolve): the full docs carry everything the
207
+ // provider factory needs, so there is no N+1 findById per connection.
208
+ const docs = (await this.mainDbModel
209
+ .find({ enabled: { $ne: false } })
210
+ .lean()
211
+ .exec()) as unknown as ResolvableConnectionDoc[];
212
+ for (const doc of docs) {
213
+ // Only a connection that DECLARES a capability can drift; undefined flags are
214
+ // auto-detected on first use, so there is nothing to reconcile here.
215
+ if (typeof doc.supportsNativeTools !== 'boolean' && typeof doc.supportsJsonResponse !== 'boolean') {
216
+ continue;
217
+ }
218
+ let provider: { detectCapabilities?: () => Promise<{ jsonResponse?: boolean; nativeTools?: boolean }> };
219
+ try {
220
+ // Clear the declared flags so detectCapabilities() actually probes them (it
221
+ // skips any flag that is already a boolean on the connection).
222
+ const probeConnection: ResolvedAiConnection = {
223
+ apiKey: this.resolveApiKeyFromDoc(doc) ?? '',
224
+ baseUrl: doc.baseUrl,
225
+ contextWindow: doc.contextWindow,
226
+ defaultMaxTokens: doc.defaultMaxTokens,
227
+ defaultTemperature: doc.defaultTemperature,
228
+ defaultUserMaxPeriod: doc.defaultUserMaxPeriod,
229
+ defaultUserMaxTokens: doc.defaultUserMaxTokens,
230
+ id: String(doc._id),
231
+ model: doc.model,
232
+ name: doc.name,
233
+ providerType: doc.providerType || 'openai-compatible',
234
+ supportsJsonResponse: undefined,
235
+ supportsNativeTools: undefined,
236
+ };
237
+ provider = this.providerFactory.create(probeConnection);
238
+ } catch {
239
+ continue; // unresolvable / unbuildable — nothing to compare against
240
+ }
241
+ if (typeof provider.detectCapabilities !== 'function') {
242
+ continue;
243
+ }
244
+ const detected = await provider.detectCapabilities().catch(() => undefined);
245
+ if (!detected) {
246
+ continue; // probe failed (endpoint down / transport error) — not a drift signal
247
+ }
248
+ const drift: string[] = [];
249
+ if (
250
+ typeof doc.supportsNativeTools === 'boolean' &&
251
+ typeof detected.nativeTools === 'boolean' &&
252
+ doc.supportsNativeTools !== detected.nativeTools
253
+ ) {
254
+ drift.push(
255
+ `supportsNativeTools declared ${doc.supportsNativeTools} but the endpoint reports ${detected.nativeTools}`,
256
+ );
257
+ }
258
+ if (
259
+ typeof doc.supportsJsonResponse === 'boolean' &&
260
+ typeof detected.jsonResponse === 'boolean' &&
261
+ doc.supportsJsonResponse !== detected.jsonResponse
262
+ ) {
263
+ drift.push(
264
+ `supportsJsonResponse declared ${doc.supportsJsonResponse} but the endpoint reports ${detected.jsonResponse}`,
265
+ );
266
+ }
267
+ if (drift.length) {
268
+ this.logger.warn(
269
+ `AI connection "${doc.name || String(doc._id)}" capability drift: ${drift.join('; ')}. ` +
270
+ `The declared value is authoritative and was NOT changed — correct it in the admin UI, or clear it to ` +
271
+ `re-enable auto-detection, so the assistant uses the endpoint's real capabilities.`,
272
+ );
273
+ }
274
+ }
275
+ } catch (err) {
276
+ this.logger.warn(`AI capability drift check skipped: ${(err as Error).message}`);
277
+ }
278
+ }
279
+
145
280
  /**
146
281
  * Create a connection. Encrypts the optional plaintext `apiKey` and keeps the
147
282
  * default connection unique.