@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.
- package/.claude/rules/testing.md +3 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +2 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +5 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +52 -3
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.41.0-to-11.41.1.md +114 -0
- package/package.json +1 -1
- package/src/core/modules/ai/interfaces/llm-provider.interface.ts +24 -0
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +147 -5
|
@@ -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.
|
|
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?: {
|
|
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]
|
|
125
|
-
const text = choice?.content ?? '';
|
|
126
|
-
const nativeToolCalls = this.capabilities.nativeTools
|
|
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
|