@lenne.tech/nest-server 11.41.0 → 11.41.1

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,114 @@
1
+ # Migration Guide: 11.41.0 → 11.41.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Effort | Applies to |
6
+ |----------|--------|-----------|
7
+ | **Bugfix** | none | Projects whose AI connection points at a reasoning model — prompts that silently returned nothing now return an answer |
8
+ | Behaviour change | none, but worth knowing | Projects using `ai.budget` — one prompt can now cost two upstream calls, and both are metered |
9
+ | New (read-only) | none | `LlmResponse.finishReason`, `LlmUsage.reasoningTokens` — both optional |
10
+
11
+ Most projects update with `pnpm update @lenne.tech/nest-server` and read no further. **Projects that
12
+ do not use the AI module are unaffected by all of it.**
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ ```bash
21
+ # Do you use the AI module at all? If this is empty, you are done.
22
+ grep -rn "CoreAiModule\|aiConnections\|AiTool" src/ 2>/dev/null
23
+ ```
24
+
25
+ ## Bugfix: a reasoning model that thinks past its budget now gets a second chance
26
+
27
+ ### What was broken
28
+
29
+ A reasoning model spends output tokens on its thinking phase **before** it writes a single character
30
+ of the answer. When that phase consumes the whole `max_tokens` allowance, the endpoint answers a
31
+ perfectly ordinary `HTTP 200` carrying `finish_reason: 'length'`, empty content, and
32
+ `reasoning_tokens == completion_tokens`.
33
+
34
+ `chat()` handed that on as an empty string. Nothing distinguished it from "the model had nothing to
35
+ say", so a consumer with a modest `maxTokens` received nothing — silently, on every prompt, for as
36
+ long as the connection pointed at such a model. Call sites that degrade to a null-fallback reported
37
+ no error at all.
38
+
39
+ Measured against an OpenAI-compatible hosting endpoint with a 900-token budget:
40
+
41
+ | Model | Default | With the thinking phase off |
42
+ |-------|---------|-----------------------------|
43
+ | Mistral-Medium-3.5-128B | empty, 900/900 spent thinking | 348 characters, 0.8 s |
44
+ | Qwen3.6-35B-A3B-FP8 | empty, 900/900 spent thinking | 399 characters, 0.8 s |
45
+ | gpt-oss-120b | 537 characters | HTTP 400 — rejects the parameter |
46
+ | Ministral-3-14B-Instruct | 1213 characters | 901 characters |
47
+
48
+ **Raising the token budget does not help.** The model spends whatever it is given: 900 of 900, 1500
49
+ of 1500, 2048 of 2048, 4096 of 4096 — always with empty content.
50
+
51
+ ### What happens now
52
+
53
+ When, and only when, a completion comes back with `finish_reason: 'length'`, no content and no tool
54
+ call, the provider retries the identical request once with `reasoning_effort: 'none'`. Everything
55
+ else is unchanged: a truncated answer that HAS content is kept (retrying would discard usable text),
56
+ an empty answer with `finish_reason: 'stop'` is kept (the model chose to say nothing, so no budget
57
+ ran out), and a tool call is kept (the model did answer, in the tool channel).
58
+
59
+ The parameter cannot be sent pre-emptively — `gpt-oss-120b` rejects it with a `400` while working
60
+ perfectly well without it. When the retry fails for any reason, the original response is returned
61
+ with its `finishReason` and usage intact, and the log names the actual failure.
62
+
63
+ ### Do I need to do anything?
64
+
65
+ **No.** There is no new configuration, and deliberately so: the behaviour only triggers on a
66
+ response that previously reached you as an empty string, which is not an outcome anyone can want.
67
+
68
+ ## Behaviour change: one prompt can now cost two upstream calls
69
+
70
+ Worth knowing if you run with `ai.budget` limits or watch provider spend.
71
+
72
+ On the retry path both calls are genuinely billed by the provider, so **both are reported in
73
+ `usage`** — `promptTokens`, `completionTokens` and `reasoningTokens` are the sum of the two. That is
74
+ what keeps `ai.budget` honest: the accounting sums `totalTokens` from the audit records, so
75
+ reporting only the retry would hide the starved call, which by definition burned the entire
76
+ `max_tokens` allowance, from the very limit meant to bound it.
77
+
78
+ Two consequences:
79
+
80
+ - On a model that starves on every prompt, real token spend against a configured limit is roughly
81
+ double what the same workload cost before — because it always was, and is now measured. If you
82
+ set `ai.budget.user.maxTokens` against observed usage from 11.41.0, re-check the number.
83
+ - `contextWindow.used` counts the prompt twice on that path, since the same prompt really was sent
84
+ twice. It is clamped to the window size, so it can saturate at 100 % but never report nonsense.
85
+
86
+ The retry shares the original call's timeout budget rather than starting a fresh one, so the
87
+ documented ceiling of `maxIterations` × the per-call timeout still holds. A connection whose entire
88
+ `timeoutMs` is below one second never retries.
89
+
90
+ ## New: two optional fields on the provider contract
91
+
92
+ ```ts
93
+ interface LlmResponse {
94
+ /** 'stop', 'length', 'tool_calls', … — undefined when the backend omits it. */
95
+ finishReason?: string;
96
+ }
97
+
98
+ interface LlmUsage {
99
+ /** Output tokens spent thinking. Part of completionTokens, not additional to it. */
100
+ reasoningTokens?: number;
101
+ }
102
+ ```
103
+
104
+ Both are optional additions, so every existing `ILlmProvider` implementation and every `LlmResponse`
105
+ literal still compiles unchanged. They exist because without them a caller cannot tell an answer
106
+ apart from a fragment: `length` means the budget ran out mid-flight, so short or empty text is a
107
+ truncation rather than the model's verdict.
108
+
109
+ ## Under the hood
110
+
111
+ `OpenAiCompatibleProvider` gained two protected seams a subclass can override: `postCompletion()`
112
+ (one request, mapped to `LlmResponse`) and `isReasoningStarved()` (the detection predicate). The SSRF
113
+ egress allowlist (`ai.allowedBaseUrlHosts`) is applied before both calls, as before — the retry
114
+ reuses the already-validated URL and changes only the request body.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.41.0",
3
+ "version": "11.41.1",
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",
@@ -100,6 +100,17 @@ export interface LlmToolCall {
100
100
  export interface LlmUsage {
101
101
  completionTokens?: number;
102
102
  promptTokens?: number;
103
+ /**
104
+ * Output tokens the model spent THINKING before answering, where the backend
105
+ * reports them (`completion_tokens_details.reasoning_tokens`). Part of
106
+ * {@link completionTokens}, not additional to it.
107
+ *
108
+ * Worth surfacing because the thinking phase competes with the answer for the
109
+ * SAME budget: when it equals `completionTokens` the model never got to the
110
+ * answer, which is a very different failure from a model that had nothing to
111
+ * say. See {@link LlmResponse.finishReason}.
112
+ */
113
+ reasoningTokens?: number;
103
114
  totalTokens?: number;
104
115
  }
105
116
 
@@ -140,6 +151,19 @@ export interface LlmCompletionOptions {
140
151
  * Normalized response of a single LLM completion.
141
152
  */
142
153
  export interface LlmResponse {
154
+ /**
155
+ * Why the model stopped, as reported by the backend (`stop`, `length`,
156
+ * `tool_calls`, …). Undefined when the backend omits it.
157
+ *
158
+ * Without this a caller cannot tell an answer apart from a fragment: `length`
159
+ * means the output budget ran out mid-flight, so short or empty text is a
160
+ * truncation and not the model's verdict. Retrying an identical request is
161
+ * pointless in that case — and actively misleading against a backend that
162
+ * caches identical prompts, which answers the retry from cache in
163
+ * milliseconds.
164
+ */
165
+ finishReason?: string;
166
+
143
167
  /** Raw provider payload (for debugging/audit, never sent to clients). */
144
168
  raw?: unknown;
145
169
 
@@ -9,6 +9,7 @@ import {
9
9
  LlmMessage,
10
10
  LlmResponse,
11
11
  LlmToolSchema,
12
+ LlmUsage,
12
13
  } from '../interfaces/llm-provider.interface';
13
14
  import { ResolvedAiConnection } from '../interfaces/resolved-ai-connection.interface';
14
15
 
@@ -29,6 +30,22 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
29
30
  readonly capabilities: LlmCapabilities;
30
31
  readonly name = 'openai-compatible';
31
32
 
33
+ /**
34
+ * Floor below which the reasoning retry is not attempted at all.
35
+ *
36
+ * The retry shares the ORIGINAL call's timeout budget (see {@link chat}), so a
37
+ * first call that nearly exhausted it leaves too little for a second. Starting one
38
+ * anyway would spend the remainder waiting for a request that cannot finish, and
39
+ * then report the timeout as "the model rejects reasoning_effort". Measured retries
40
+ * answer in well under a second once the thinking phase is off.
41
+ *
42
+ * Consequence worth stating: a connection whose whole `timeoutMs` is below this floor
43
+ * never retries at all. That is the right trade for a value this far below any usable
44
+ * LLM timeout (the default is 120 s), but it is a behaviour the number decides, so it
45
+ * belongs here rather than in a reader's head.
46
+ */
47
+ protected static readonly MIN_REASONING_RETRY_MS = 1_000;
48
+
32
49
  private readonly logger = new Logger(OpenAiCompatibleProvider.name);
33
50
  private readonly defaultTimeoutMs: number;
34
51
 
@@ -93,6 +110,93 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
93
110
  }
94
111
 
95
112
  const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
113
+ const startedAt = Date.now();
114
+ const answer = await this.postCompletion(url, body, timeoutMs);
115
+ if (!this.isReasoningStarved(answer)) {
116
+ return answer;
117
+ }
118
+
119
+ // The thinking phase ate the whole budget before a single character of the
120
+ // answer. Say so with the numbers — the failure is otherwise indistinguishable
121
+ // in a log from "the model had nothing to say", which is what made it so
122
+ // expensive to diagnose.
123
+ this.logger.warn(
124
+ `AI completion for model "${body.model}" returned NO content: finish_reason=${answer.finishReason}, ` +
125
+ `${answer.usage?.reasoningTokens ?? 0} of ${answer.usage?.completionTokens ?? 0} output tokens were spent ` +
126
+ `thinking against a budget of ${body.max_tokens}. Retrying once without the thinking phase.`,
127
+ );
128
+ // The retry shares the ORIGINAL call's timeout budget instead of starting a fresh
129
+ // one. `CoreAiService` checks `ai.maxRunMs` BETWEEN agent-loop iterations and never
130
+ // mid-call, so a second full timeout here would silently double the ceiling the
131
+ // framework documents as `maxIterations` x the per-call timeout — 20 minutes instead
132
+ // of 10 at the defaults. On the SSE path that is time the client spends in total
133
+ // silence: `promptStream()` yields nothing but tool actions until the run settles,
134
+ // and a starved completion produces no tool call to report.
135
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
136
+ if (remainingMs < OpenAiCompatibleProvider.MIN_REASONING_RETRY_MS) {
137
+ this.logger.warn(
138
+ `Not retrying model "${body.model}" without the thinking phase: only ${Math.max(0, remainingMs)}ms of the ` +
139
+ `${timeoutMs}ms budget remain. Keeping the original, empty completion.`,
140
+ );
141
+ return answer;
142
+ }
143
+
144
+ try {
145
+ const retried = await this.postCompletion(url, { ...body, reasoning_effort: 'none' }, remainingMs);
146
+ // BOTH calls were billed upstream, so both must reach the caller. `CoreAiService`
147
+ // accumulates only what `chat()` returns, that total lands in the audit record's
148
+ // `totalTokens`, and `CoreAiBudgetService` enforces `ai.budget` from exactly that
149
+ // field. Returning the retry's usage alone would hide the STARVED call — the one
150
+ // that by definition burned the entire `max_tokens` allowance — from the limit
151
+ // that exists to bound it.
152
+ return { ...retried, usage: this.mergeUsage(answer.usage, retried.usage) };
153
+ } catch (err) {
154
+ // Usually a 400: the backend does not accept `reasoning_effort` for this model.
155
+ // But the same catch also sees timeouts and transport failures, and naming the
156
+ // 400 for one of those would send the reader after a cause that is not there —
157
+ // the precise kind of misdirection this whole change exists to remove. Report
158
+ // what actually happened and let the original answer stand: it keeps its
159
+ // `finishReason` and the usage the caller already paid for.
160
+ this.logger.warn(
161
+ `Retry without the thinking phase failed for model "${body.model}" ` +
162
+ `(${(err as Error)?.message ?? 'unknown error'}) — keeping the original, empty completion. ` +
163
+ 'Raise the token budget or configure a model that answers within it.',
164
+ );
165
+ return answer;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Add up the usage of the two calls a retried completion actually made.
171
+ *
172
+ * Kept separate from {@link chat} because "what did this run cost" is a question the
173
+ * budget, the audit record and the client's usage summary all read from one number,
174
+ * and a provider that reports only half of it under-enforces every limit built on it.
175
+ *
176
+ * A field absent from BOTH sides stays absent — a backend that reports no breakdown
177
+ * must not be made to look like it reported zero.
178
+ */
179
+ protected mergeUsage(first?: LlmUsage, second?: LlmUsage): LlmUsage | undefined {
180
+ if (!first) {
181
+ return second;
182
+ }
183
+ if (!second) {
184
+ return first;
185
+ }
186
+ const sum = (a?: number, b?: number) => (a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0));
187
+ return {
188
+ completionTokens: sum(first.completionTokens, second.completionTokens),
189
+ promptTokens: sum(first.promptTokens, second.promptTokens),
190
+ reasoningTokens: sum(first.reasoningTokens, second.reasoningTokens),
191
+ totalTokens: sum(first.totalTokens, second.totalTokens),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * POST one completion and map it to {@link LlmResponse}. Transport failures and
197
+ * non-2xx responses throw, exactly as a single-shot `chat()` always did.
198
+ */
199
+ protected async postCompletion(url: string, body: Record<string, any>, timeoutMs: number): Promise<LlmResponse> {
96
200
  let response: Response;
97
201
  try {
98
202
  response = await fetch(url, {
@@ -117,26 +221,64 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
117
221
  }
118
222
 
119
223
  const result = (await response.json()) as {
120
- choices?: { message?: { content?: string; tool_calls?: any[] } }[];
121
- usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number };
224
+ choices?: { finish_reason?: string; message?: { content?: string; tool_calls?: any[] } }[];
225
+ usage?: {
226
+ completion_tokens?: number;
227
+ completion_tokens_details?: { reasoning_tokens?: number };
228
+ prompt_tokens?: number;
229
+ total_tokens?: number;
230
+ };
122
231
  };
123
232
 
124
- const choice = result.choices?.[0]?.message;
125
- const text = choice?.content ?? '';
126
- const nativeToolCalls = this.capabilities.nativeTools ? this.mapNativeToolCalls(choice?.tool_calls) : undefined;
233
+ const choice = result.choices?.[0];
234
+ const text = choice?.message?.content ?? '';
235
+ const nativeToolCalls = this.capabilities.nativeTools
236
+ ? this.mapNativeToolCalls(choice?.message?.tool_calls)
237
+ : undefined;
127
238
 
128
239
  return {
240
+ finishReason: choice?.finish_reason,
129
241
  raw: result,
130
242
  text,
131
243
  toolCalls: nativeToolCalls,
132
244
  usage: {
133
245
  completionTokens: result.usage?.completion_tokens,
134
246
  promptTokens: result.usage?.prompt_tokens,
247
+ reasoningTokens: result.usage?.completion_tokens_details?.reasoning_tokens,
135
248
  totalTokens: result.usage?.total_tokens,
136
249
  },
137
250
  };
138
251
  }
139
252
 
253
+ /**
254
+ * True when the output budget was exhausted before the model produced ANY
255
+ * answer — a reasoning model that spent every token on its thinking phase.
256
+ *
257
+ * The backend answers `200` with `finish_reason: 'length'`, empty content and
258
+ * (where it reports the breakdown) `reasoning_tokens == completion_tokens`.
259
+ * Measured against an OpenAI-compatible hosting endpoint on 2026-09-07 with a 900-token budget:
260
+ * `Mistral-Medium-3.5-128B` and `Qwen3.6-35B-A3B-FP8` both return nothing,
261
+ * while `gpt-oss-120b` and `Ministral-3-14B-Instruct` answer normally.
262
+ *
263
+ * The narrowness is deliberate, in both directions:
264
+ *
265
+ * - **Content present** → the answer merely got cut short. That is a budget
266
+ * question the caller can now see via `finishReason`, and discarding the
267
+ * partial text to re-ask would lose something usable.
268
+ * - **`finish_reason: 'stop'` with empty content** → the model chose to say
269
+ * nothing. No budget ran out, so removing the thinking phase addresses
270
+ * nothing and would cost a second upstream call on every such answer.
271
+ * - **Tool calls present** → the model DID answer, in the tool channel.
272
+ *
273
+ * `reasoning_tokens` is treated as corroborating, not required: backends that
274
+ * omit the breakdown produce exactly the same symptom, and the retry is
275
+ * harmless where the diagnosis is wrong (one extra call on a request that
276
+ * returned nothing either way).
277
+ */
278
+ protected isReasoningStarved(response: LlmResponse): boolean {
279
+ return response.finishReason === 'length' && !response.text && !response.toolCalls?.length;
280
+ }
281
+
140
282
  /**
141
283
  * Optional SSRF hardening: when `ai.allowedBaseUrlHosts` is configured (non-empty),
142
284
  * only allow requests to those hosts (matched by `host` incl. port, or bare