@lenne.tech/nest-server 11.39.0 → 11.41.0

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 (32) hide show
  1. package/.claude/rules/configurable-features.md +3 -0
  2. package/.claude/rules/module-inheritance.md +2 -0
  3. package/.claude/rules/package-management.md +51 -2
  4. package/.claude/rules/testing.md +182 -3
  5. package/CLAUDE.md +13 -1
  6. package/FRAMEWORK-API.md +3 -2
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
  8. package/dist/core/modules/ai/core-ai.controller.js +6 -0
  9. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  10. package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +1 -0
  11. package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
  12. package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
  13. package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
  14. package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
  15. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +6 -0
  16. package/dist/core/modules/ai/providers/openai-compatible.provider.js +106 -12
  17. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  18. package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
  19. package/dist/core/modules/ai/services/core-ai.service.js +131 -13
  20. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/migration-guides/11.39.0-to-11.40.0.md +186 -0
  23. package/migration-guides/11.40.0-to-11.41.0.md +118 -0
  24. package/package.json +5 -4
  25. package/src/core/common/interfaces/server-options.interface.ts +34 -1
  26. package/src/core/modules/ai/README.md +59 -5
  27. package/src/core/modules/ai/core-ai.controller.ts +16 -0
  28. package/src/core/modules/ai/interfaces/llm-provider.interface.ts +15 -0
  29. package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
  30. package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
  31. package/src/core/modules/ai/providers/openai-compatible.provider.ts +309 -15
  32. package/src/core/modules/ai/services/core-ai.service.ts +333 -23
@@ -77,7 +77,18 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
77
77
  type: 'function',
78
78
  }));
79
79
  }
80
- if (this.capabilities.jsonResponse) {
80
+ // A per-request `model` overrides the connection's — but the capability flags
81
+ // were probed against `connection.model` and persisted per CONNECTION, never
82
+ // per model. Applying them to a different model asserts something that was
83
+ // never measured: the endpoint may reject `response_format` for it, and the
84
+ // caller sees a transport error where it expected an answer. Fall back to the
85
+ // safe subset (prompt-driven JSON + defensive parsing) whenever the model the
86
+ // request actually targets is not the one the probe ran against.
87
+ // `options.jsonResponse === false` narrows this off for a single call — the way a
88
+ // caller whose PROMPT asks for prose says so. It can only ever narrow: the flag
89
+ // was measured against this connection, so no option may assert it where the
90
+ // probe never ran.
91
+ if (this.capabilities.jsonResponse && options?.jsonResponse !== false && body.model === this.connection.model) {
81
92
  body.response_format = { type: 'json_object' };
82
93
  }
83
94
 
@@ -134,8 +145,8 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
134
145
  * admin, not an end-user input.
135
146
  */
136
147
  protected assertBaseUrlAllowed(url: string): void {
137
- const allowedHosts = ConfigService.get<string[]>('ai.allowedBaseUrlHosts');
138
- if (!Array.isArray(allowedHosts) || !allowedHosts.length) {
148
+ const allowedHosts = this.resolveAllowedBaseUrlHosts();
149
+ if (!allowedHosts.length) {
139
150
  return;
140
151
  }
141
152
  let parsed: URL;
@@ -144,7 +155,17 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
144
155
  } catch {
145
156
  throw new ServiceUnavailableException(ErrorCode.AI_CONNECTION_INVALID_URL);
146
157
  }
147
- if (!allowedHosts.includes(parsed.host) && !allowedHosts.includes(parsed.hostname)) {
158
+ // A hostname entry stays host-wide (any port); the extra candidates only make an
159
+ // operator who was MORE explicit than necessary succeed rather than fail. `URL.host`
160
+ // omits the default port, so a conscientious `llm.example.com:443` entry would
161
+ // otherwise never match `https://llm.example.com/` — a lockout whose only symptom is
162
+ // a WARN and "the AI stopped working". Nothing here widens the set of reachable hosts.
163
+ const defaultPort = parsed.protocol === 'https:' ? '443' : parsed.protocol === 'http:' ? '80' : '';
164
+ const candidates = [parsed.host, parsed.hostname];
165
+ if (defaultPort && parsed.host === parsed.hostname) {
166
+ candidates.push(`${parsed.hostname}:${defaultPort}`);
167
+ }
168
+ if (!candidates.map((candidate) => this.normaliseHostEntry(candidate)).some((c) => allowedHosts.includes(c))) {
148
169
  this.logger.warn(
149
170
  `AI connection "${this.connection.name}" host "${parsed.host}" is not in ai.allowedBaseUrlHosts`,
150
171
  );
@@ -152,30 +173,265 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
152
173
  }
153
174
  }
154
175
 
176
+ /**
177
+ * Output budget for the native-tool probe.
178
+ *
179
+ * A reasoning model spends output tokens on its thinking phase BEFORE it emits
180
+ * `tool_calls`. With a budget of a few tokens the endpoint answers `200` with
181
+ * `finish_reason: 'length'` and no tool call at all — which the probe used to read
182
+ * as "native tools unsupported", persisting a false negative that is never
183
+ * re-probed and degrades the assistant to emulated tool calling for good.
184
+ *
185
+ * Measured against an OpenAI-compatible hosting endpoint on 2026-07-25
186
+ * (`tool_choice: 'required'` plus a trivial `ping` tool, varying only
187
+ * `max_tokens`):
188
+ *
189
+ * | model | 8 | 64 | 256 |
190
+ * |--------------------------|----|----|-----|
191
+ * | Ministral-3-14B-Instruct | ✅ | ✅ | ✅ |
192
+ * | Mistral-Medium-3.5-128B | ✅ | ✅ | ✅ |
193
+ * | gpt-oss-120b | ❌ | ✅ | ✅ |
194
+ * | Qwen3.5-122B-A10B-FP8 | ❌ | ✅ | ✅ |
195
+ * | Qwen3.6-35B-A3B-FP8 | ❌ | ❌ | ✅ |
196
+ *
197
+ * 256 covers every model tested and costs a few hundred tokens ONCE per
198
+ * connection, which is nothing against the cost of the wrong flag.
199
+ */
200
+ protected static readonly NATIVE_TOOL_PROBE_MAX_TOKENS = 256;
201
+
202
+ /**
203
+ * Second and FINAL budget for the native-tool probe, used only when the first
204
+ * attempt came back truncated.
205
+ *
206
+ * The retry has to be bounded, and the bound has to live here. An inconclusive
207
+ * result leaves the capability `undefined`, `detectAndPersistCapabilities` only
208
+ * persists booleans, and the orchestrator re-runs detection whenever a flag is
209
+ * undefined — so "just leave it undetected and try again later" would fire one
210
+ * extra upstream completion before EVERY user prompt, ahead of the rate limiter
211
+ * and outside any budget accounting. Two attempts, then a definite answer.
212
+ *
213
+ * Returning `false` after a model failed to emit a tool call within 1024 output
214
+ * tokens is not the false negative this fix exists to prevent: a model that needs
215
+ * more than that before its first tool call cannot drive an agent loop usefully
216
+ * anyway, and emulated tool calling is the correct fallback for it.
217
+ */
218
+ protected static readonly NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY = 1024;
219
+
220
+ /**
221
+ * The configured egress allowlist as a lowercase array, whatever shape it has.
222
+ *
223
+ * A STRING is split as CSV rather than rejected, because `ai.allowedBaseUrlHosts`
224
+ * is reachable through the framework's own `NSC__AI__ALLOWED_BASE_URL_HOSTS`
225
+ * environment mapping: `getEnvironmentObject()` turns that variable into
226
+ * `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns the
227
+ * scalar straight over the configured array. A bare `!Array.isArray(...) -> return`
228
+ * then reads it as "no allowlist configured" and skips the check entirely — so an
229
+ * operator using the canonical `NSC__` spelling silently disables SSRF egress
230
+ * control, with no log line and no error. A malformed security setting must be
231
+ * interpreted or fail CLOSED, never fail open.
232
+ *
233
+ * Entries are lowercased because `URL.host` / `URL.hostname` always are; a
234
+ * differently-cased entry would otherwise fail closed for no stated reason, and
235
+ * the only symptom would be a WARN log plus "the AI stopped working".
236
+ *
237
+ * A value that is NEITHER an array nor a string carries no hostnames and cannot be
238
+ * interpreted — `NSC__AI__ALLOWED_BASE_URL_HOSTS=0` coerces to a number, and
239
+ * `NEST_SERVER_CONFIG` can deliver an object. Returning an empty list there is the
240
+ * only honest answer, but it reopens egress while the operator believes the control
241
+ * is on, so it is LOGGED every time rather than passed over in silence. That is the
242
+ * difference between this and the documented unset-is-permissive default: unset is a
243
+ * decision, a malformed value is an accident nobody is told about.
244
+ */
245
+ protected resolveAllowedBaseUrlHosts(): string[] {
246
+ const configured = ConfigService.get<unknown>('ai.allowedBaseUrlHosts');
247
+ if (configured === undefined || configured === null) {
248
+ return [];
249
+ }
250
+ const entries = typeof configured === 'string' ? configured.split(',') : configured;
251
+ if (!Array.isArray(entries)) {
252
+ this.logger.error(
253
+ `ai.allowedBaseUrlHosts is a ${typeof configured} and carries no hostnames — the SSRF egress ` +
254
+ 'allowlist is NOT active. Use an array or a comma-separated string.',
255
+ );
256
+ return [];
257
+ }
258
+ return entries.map((host) => this.normaliseHostEntry(String(host))).filter(Boolean);
259
+ }
260
+
261
+ /**
262
+ * Lowercase, trim, and drop a fully-qualifying trailing dot.
263
+ *
264
+ * Applied to BOTH the allowlist entry and the URL being checked, so neither side can
265
+ * win by spelling the same DNS name differently. `llm.example.com.` and
266
+ * `llm.example.com` resolve identically, so treating them as different hosts only ever
267
+ * produced a confusing refusal — never protection.
268
+ */
269
+ protected normaliseHostEntry(value: string): string {
270
+ return value
271
+ .trim()
272
+ .toLowerCase()
273
+ .replace(/\.(?=$|:)/, '');
274
+ }
275
+
155
276
  /**
156
277
  * Probe the backend to auto-detect capabilities for flags the connection left
157
278
  * undefined. Explicit flags are authoritative and are NOT probed. Best effort:
158
- * - JSON: send `response_format: json_object`; 2xx true, 4xx → false.
279
+ * - JSON: send `response_format: json_object`; 2xx AND content that actually
280
+ * parses as JSON → true. A 2xx alone is not evidence — a backend that does not
281
+ * implement `response_format` simply ignores the field and answers normally.
159
282
  * - Native tools: send a trivial tool with `tool_choice: 'required'`; 2xx WITH a
160
- * `tool_calls` result → true, otherwise false (a backend that silently ignores
161
- * tools returns no tool_calls and is treated as unsupported).
283
+ * `tool_calls` result → true. A backend that answers fully but silently ignores
284
+ * the tools returns no tool_calls and IS a real negative. A response truncated
285
+ * by the token budget (`finish_reason: 'length'` without tool_calls) proves
286
+ * nothing, so it is retried ONCE with
287
+ * {@link NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY}; a second truncation resolves to
288
+ * `false`.
289
+ *
290
+ * This method always returns a definite boolean for a flag it probed. That is
291
+ * deliberate: an `undefined` result is not persisted by
292
+ * `detectAndPersistCapabilities`, and the orchestrator re-runs detection whenever
293
+ * a flag is undefined — so an endpoint that keeps truncating would trigger one
294
+ * extra upstream completion before every user prompt, ahead of the rate limiter
295
+ * and outside budget accounting.
162
296
  *
163
297
  * Throws on a transport error so callers can treat the connection as undetected
164
298
  * (and retry later) rather than persisting a wrong value.
165
299
  */
166
300
  async detectCapabilities(): Promise<{ jsonResponse?: boolean; nativeTools?: boolean }> {
301
+ const needsJson = this.connection.supportsJsonResponse === undefined;
302
+ const needsTools = this.connection.supportsNativeTools === undefined;
303
+ // Independent upstream calls, so run them together. This matters more since both
304
+ // probes gained a truncation retry: sequentially, a fresh connection can now cost
305
+ // FOUR round trips before the user's own completion starts — and lazy detection
306
+ // sits inline on the interactive prompt path.
307
+ const [jsonResponse, nativeTools] = await Promise.all([
308
+ needsJson ? this.probeJsonResponse() : Promise.resolve(undefined),
309
+ needsTools ? this.probeNativeTools() : Promise.resolve(undefined),
310
+ ]);
167
311
  const result: { jsonResponse?: boolean; nativeTools?: boolean } = {};
168
- if (this.connection.supportsJsonResponse === undefined) {
312
+ if (needsJson) {
313
+ result.jsonResponse = jsonResponse;
314
+ }
315
+ if (needsTools) {
316
+ result.nativeTools = nativeTools;
317
+ }
318
+ return result;
319
+ }
320
+
321
+ /**
322
+ * Probe structured-JSON support.
323
+ *
324
+ * A 2xx alone is NOT evidence: a backend that does not implement
325
+ * `response_format` typically ignores the unknown field and answers normally, so
326
+ * trusting the status code alone persists `supportsJsonResponse: true` for an
327
+ * endpoint that never honours it — after which `chat()` sends `response_format`
328
+ * on every single call. Like the native-tool flag this is written once and never
329
+ * re-probed, so the wrong value is permanent.
330
+ *
331
+ * The response content must therefore actually parse as JSON. The budget matches
332
+ * the tool probe for the same reason: a reasoning model needs room to get past
333
+ * its thinking phase before it emits anything parseable — INCLUDING the tool
334
+ * probe's retry on truncation, which this probe needs just as badly.
335
+ *
336
+ * Measured against an OpenAI-compatible hosting endpoint on 2026-09-03 (this
337
+ * same probe, varying only `max_tokens`; the number is the completion tokens the
338
+ * model actually spent):
339
+ *
340
+ * | model | 256 | 1024 |
341
+ * |-------------------------------|--------------|--------|
342
+ * | Ministral-3-14B-Instruct-2512 | ✅ 9 | — |
343
+ * | gpt-oss-120b | ✅ 88 | — |
344
+ * | Qwen3.6-35B-A3B-FP8 | ✅ 202 | — |
345
+ * | Mistral-Medium-3.5-128B | ❌ truncated | ✅ 878 |
346
+ * | Qwen3.5-122B-A10B-FP8 | ❌ truncated | ✅ 365 |
347
+ *
348
+ * TWO of five need the retry — so without it the probe records "structured JSON
349
+ * unsupported" for an endpoint that demonstrably supports it.
350
+ *
351
+ * That false negative costs on two different paths, and the expensive one is the
352
+ * quiet one. `detectAndPersistCapabilities` WRITES what the probe returns, and a
353
+ * written flag is authoritative and never re-probed — so a fresh connection whose
354
+ * flags are unset is pinned to the wrong `false` for good, silently falling back
355
+ * to prompt-driven JSON. The loud path is the `ai.capabilityDriftCheck` boot
356
+ * warning (`supportsJsonResponse declared true but the endpoint reports false`),
357
+ * which merely reads as endpoint drift and sends the next reader hunting for one
358
+ * — which is how this was found. Note the warning fires only where that
359
+ * opt-in check is enabled, while the persisted flag is wrong everywhere.
360
+ *
361
+ * A COMPLETE answer that merely is not JSON stays a real negative: the endpoint
362
+ * ignored `response_format`, and a larger budget cannot change that.
363
+ */
364
+ protected async probeJsonResponse(): Promise<boolean> {
365
+ const budgets = [
366
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS,
367
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY,
368
+ ];
369
+
370
+ for (const [attempt, maxTokens] of budgets.entries()) {
169
371
  const res = await this.probe({
170
- max_tokens: 8,
372
+ max_tokens: maxTokens,
171
373
  messages: [{ content: 'Reply with the JSON object {"ok":true}.', role: 'user' }],
172
374
  response_format: { type: 'json_object' },
173
375
  });
174
- result.jsonResponse = res.ok;
376
+ if (!res.ok) {
377
+ return false;
378
+ }
379
+ const choice = res.json?.choices?.[0];
380
+ const content = choice?.message?.content;
381
+ const truncated = choice?.finish_reason === 'length';
382
+ if (typeof content === 'string' && content.trim()) {
383
+ try {
384
+ JSON.parse(content);
385
+ return true;
386
+ } catch {
387
+ // A parse failure is only a REAL negative when the model finished on its
388
+ // own terms. Truncation is the other reason JSON does not parse, and it
389
+ // arrives in two shapes depending on how the model emits: a reasoning
390
+ // model buffers behind its thinking phase and returns EMPTY content, while
391
+ // one that streams directly returns a partial body like `{"ok":tr`. Both
392
+ // are `finish_reason: 'length'`, and classifying the partial one here
393
+ // instead of retrying reaches the exact false negative this ladder exists
394
+ // to remove — just through the other door.
395
+ if (!truncated) {
396
+ this.logger.warn(
397
+ `JSON-response probe for model "${this.connection.model}" returned non-JSON content — ` +
398
+ 'recording structured JSON as unsupported',
399
+ );
400
+ return false;
401
+ }
402
+ }
403
+ } else if (!truncated) {
404
+ // Empty content that finished on its own terms is a real negative.
405
+ return false;
406
+ }
407
+
408
+ const isLastAttempt = attempt === budgets.length - 1;
409
+ this.logger.warn(
410
+ `JSON-response probe for model "${this.connection.model}" was truncated (finish_reason=length) at ` +
411
+ `max_tokens=${maxTokens}` +
412
+ (isLastAttempt
413
+ ? ' on the final attempt — recording structured JSON as unsupported; a model that cannot emit a ' +
414
+ 'trivial JSON object within a usable output budget is better served by the prompt-driven fallback'
415
+ : ' — retrying once with a larger budget'),
416
+ );
175
417
  }
176
- if (this.connection.supportsNativeTools === undefined) {
418
+
419
+ return false;
420
+ }
421
+
422
+ /**
423
+ * Run the native-tool probe, escalating the output budget once on truncation.
424
+ * Extracted so the retry policy is overridable and testable on its own.
425
+ */
426
+ protected async probeNativeTools(): Promise<boolean> {
427
+ const budgets = [
428
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS,
429
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY,
430
+ ];
431
+
432
+ for (const [attempt, maxTokens] of budgets.entries()) {
177
433
  const res = await this.probe({
178
- max_tokens: 8,
434
+ max_tokens: maxTokens,
179
435
  messages: [{ content: 'Call the ping tool.', role: 'user' }],
180
436
  tool_choice: 'required',
181
437
  tools: [
@@ -189,9 +445,29 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
189
445
  },
190
446
  ],
191
447
  });
192
- result.nativeTools = res.ok && !!res.json?.choices?.[0]?.message?.tool_calls?.length;
448
+ const choice = res.json?.choices?.[0];
449
+
450
+ if (res.ok && choice?.message?.tool_calls?.length) {
451
+ return true;
452
+ }
453
+ // A non-2xx, or a complete answer without tool calls, is a REAL negative —
454
+ // retrying with a bigger budget would not change it.
455
+ if (!res.ok || choice?.finish_reason !== 'length') {
456
+ return false;
457
+ }
458
+
459
+ const isLastAttempt = attempt === budgets.length - 1;
460
+ this.logger.warn(
461
+ `Native-tool probe for model "${this.connection.model}" was truncated (finish_reason=length) at ` +
462
+ `max_tokens=${maxTokens}` +
463
+ (isLastAttempt
464
+ ? ' on the final attempt — recording native tools as unsupported; the model does not reach a tool ' +
465
+ 'call within a usable output budget, so emulated tool calling is the correct fallback'
466
+ : ' — retrying once with a larger budget'),
467
+ );
193
468
  }
194
- return result;
469
+
470
+ return false;
195
471
  }
196
472
 
197
473
  /**
@@ -240,7 +516,18 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
240
516
  128_000,
241
517
  ['gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'o1', 'o3', 'gpt-oss', 'mistral-large', 'mistral-small3', 'command-r'],
242
518
  ],
243
- [131_072, ['qwen2.5', 'qwen3', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama3.3']],
519
+ // Two DIFFERENT gaps, both closed here (measured 2026-07-25 against an
520
+ // OpenAI-compatible hosting endpoint; both models verified to accept >=163k
521
+ // prompt tokens):
522
+ // - `mistral-medium` DID match the generic `mistral` -> 32768 entry below,
523
+ // capping a 256k model at an eighth of its window. It must therefore be
524
+ // matched before it -- this bucket is evaluated first.
525
+ // - `ministral` matched NOTHING at all: "ministral" does not contain the
526
+ // substring "mistral" (m-i-n-i-s-t-r-a-l), so it fell through the whole
527
+ // table to the conservative 8192 default.
528
+ // Pinned one power of two below the verified capacity so the orchestrator
529
+ // trims before the endpoint rejects.
530
+ [131_072, ['qwen2.5', 'qwen3', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama3.3', 'ministral', 'mistral-medium']],
244
531
  [65_536, ['mixtral']],
245
532
  [32_768, ['qwen2', 'mistral', 'gemma2', 'gemma-2']],
246
533
  [16_385, ['gpt-3.5']],
@@ -263,6 +550,13 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
263
550
  if (!base.startsWith('http')) {
264
551
  return undefined;
265
552
  }
553
+ // The third outbound path from the same admin-controlled baseUrl, and the one the
554
+ // allowlist used to miss. It is NOT admin-only in practice: CoreAiService calls
555
+ // detectAndPersistCapabilities() on an ordinary user prompt whenever contextWindow is
556
+ // undefined, and it runs BEFORE checkRateLimit(). A guard applied to two of three
557
+ // egress paths is not a guard. Throwing is right here — detectContextWindow() already
558
+ // wraps this call, so a refusal degrades to "context window unknown".
559
+ this.assertBaseUrlAllowed(`${base}/api/show`);
266
560
  const response = await fetch(`${base}/api/show`, {
267
561
  body: JSON.stringify({ name: this.connection.model }),
268
562
  headers: { 'Content-Type': 'application/json' },