@lenne.tech/nest-server 11.40.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/configurable-features.md +2 -0
- package/.claude/rules/testing.md +3 -3
- package/FRAMEWORK-API.md +2 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/ai/core-ai.controller.js +6 -0
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +3 -0
- package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
- package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
- package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +9 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +130 -12
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
- package/dist/core/modules/ai/services/core-ai.service.js +131 -13
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.40.0-to-11.41.0.md +118 -0
- package/migration-guides/11.41.0-to-11.41.1.md +114 -0
- package/package.json +1 -1
- package/src/core/common/interfaces/server-options.interface.ts +17 -0
- package/src/core/modules/ai/README.md +26 -5
- package/src/core/modules/ai/core-ai.controller.ts +16 -0
- package/src/core/modules/ai/interfaces/llm-provider.interface.ts +39 -0
- package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
- package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +380 -17
- package/src/core/modules/ai/services/core-ai.service.ts +333 -23
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Migration Guide: 11.40.0 → 11.41.0
|
|
2
|
+
|
|
3
|
+
> **Why a MINOR.** The MAJOR digit in this package tracks the NestJS major (11.x = NestJS 11), so it
|
|
4
|
+
> is not ours to spend — every behaviour change of our own ships as a MINOR. This release changes
|
|
5
|
+
> when the AI module asks an endpoint for JSON, which a project can notice.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
| Category | Effort | Applies to |
|
|
10
|
+
|----------|--------|-----------|
|
|
11
|
+
| **Behaviour change** | none | Projects using the AI module — three internal calls stop requesting JSON |
|
|
12
|
+
| Bugfix | none | Everyone using the AI module — capability detection and context windows get more accurate |
|
|
13
|
+
| New feature (opt-in) | none | `ai.maxRunMs` |
|
|
14
|
+
| Internal | none | Nobody — a duplicate index declaration was removed |
|
|
15
|
+
|
|
16
|
+
Most projects update with `pnpm update @lenne.tech/nest-server` and read no further. **Projects that
|
|
17
|
+
do not use the AI module are unaffected by all of it.**
|
|
18
|
+
|
|
19
|
+
## Quick Migration
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm update @lenne.tech/nest-server
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Do you use the AI module at all? If this is empty, you are done.
|
|
27
|
+
grep -rn "CoreAiModule\|aiConnections\|AiTool" src/ 2>/dev/null
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Behaviour change: JSON mode is decided per PROMPT, not per connection
|
|
31
|
+
|
|
32
|
+
### What changed
|
|
33
|
+
|
|
34
|
+
A connection with `supportsJsonResponse: true` used to send `response_format: json_object` on
|
|
35
|
+
**every** completion. It is now sent only when the call actually wants structured output.
|
|
36
|
+
|
|
37
|
+
That was wrong in both directions, and the second one is why it is worth fixing rather than leaving:
|
|
38
|
+
|
|
39
|
+
- The flag describes what the ENDPOINT can do, not what a given prompt NEEDS. A capability is not
|
|
40
|
+
an instruction.
|
|
41
|
+
- It leaked into calls that must not be JSON. The final answer to the user, and the compaction step
|
|
42
|
+
that summarises a long conversation, both want prose — and both were being asked for JSON. The
|
|
43
|
+
compaction case is the visible one: it spliced JSON-wrapped summaries into the history that the
|
|
44
|
+
model then had to read back.
|
|
45
|
+
|
|
46
|
+
### Do I need to do anything?
|
|
47
|
+
|
|
48
|
+
**No.** There is deliberately no new option to pass: `jsonResponse` lives on `LlmCompletionOptions`,
|
|
49
|
+
which is the PROVIDER-level contract, and the module sets it for itself. It is not a field on the
|
|
50
|
+
prompt input, and nothing in a consumer project needs to change.
|
|
51
|
+
|
|
52
|
+
Concretely, the narrowing is three call sites, all of which wanted prose and were being asked for
|
|
53
|
+
JSON:
|
|
54
|
+
|
|
55
|
+
| Call | Now |
|
|
56
|
+
|------|-----|
|
|
57
|
+
| The agent loop, on a connection with NATIVE tool calling | JSON mode off — the tools carry the structure |
|
|
58
|
+
| The final answer to the user | never JSON |
|
|
59
|
+
| Compaction (summarising a long conversation) | never JSON |
|
|
60
|
+
|
|
61
|
+
A connection WITHOUT native tool calling is untouched: emulated tool calling is built on
|
|
62
|
+
prompt-driven JSON, so those calls still request it.
|
|
63
|
+
|
|
64
|
+
The one thing to know is the seam the fix deliberately leaves imperfect, because a project can reach
|
|
65
|
+
it: the gate keys on native tool support, not on what the prompt asks for. A project that overrides
|
|
66
|
+
the `native` prompt slot via `CoreAiSlotService` to request JSON anyway will have it narrowed off.
|
|
67
|
+
That degrades gracefully rather than failing — prompt-driven JSON is the fallback the module is
|
|
68
|
+
built around, and its extractor is lenient — but if you have such an override and see prose where
|
|
69
|
+
you expected an object, this is why.
|
|
70
|
+
|
|
71
|
+
A custom `ILlmProvider` needs no change either: `jsonResponse` is OPTIONAL, so an implementation
|
|
72
|
+
that ignores it keeps compiling and keeps its previous behaviour.
|
|
73
|
+
|
|
74
|
+
## Bugfixes (no action required)
|
|
75
|
+
|
|
76
|
+
| Fix | What it was |
|
|
77
|
+
|-----|-------------|
|
|
78
|
+
| **Context window for two model families** | The known-model table matched by substring, so `ministral` did not match `mistral` and `mistral-medium` had no entry at all. Both fell back to the 8192 default instead of their real 131072 — silently truncating history on models that had 16x the room. Both are now listed |
|
|
79
|
+
| **Capability probes recorded permanent false negatives** | A reasoning model spends output tokens on its thinking phase BEFORE emitting `tool_calls`. With the old few-token probe budget the endpoint answered `200` with `finish_reason: 'length'` and no tool call, which read as "native tools unsupported" — persisted, never re-probed, and the assistant degraded to emulated tool calling for good. The probe now budgets 256 tokens and retries once at 1024 before answering `false` |
|
|
80
|
+
| **A thrown detection re-probed on every prompt** | A detection that threw left the capability `undefined`, and `undefined` is what triggers detection — so a transient endpoint blip fired an extra upstream completion before EVERY user prompt, ahead of the rate limiter and outside budget accounting. A 5-minute per-connection backoff now bounds it |
|
|
81
|
+
| **Duplicate `tenantId` index** | Two AI models declared an index the `mongooseTenantPlugin` already creates, producing Mongoose "Duplicate schema index" warnings. Declaration removed; the index itself is unchanged and created by the plugin as before |
|
|
82
|
+
|
|
83
|
+
## New: `ai.maxRunMs` (opt-in, off by default)
|
|
84
|
+
|
|
85
|
+
A wall-clock ceiling for ONE prompt run, checked before each agent-loop iteration.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
ai: {
|
|
89
|
+
maxRunMs: 120000, // 0 or omitted = no limit (previous behaviour)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Why it is worth setting.** Without it, a run's only bound is `maxIterations` multiplied by the
|
|
94
|
+
connection's per-call timeout — 8 iterations at the 120 s default is a request that can legitimately
|
|
95
|
+
hold a socket, its message buffer and a request context for 16 minutes, with compaction adding a
|
|
96
|
+
call per iteration on top. Set it to something a client would actually wait for.
|
|
97
|
+
|
|
98
|
+
A misconfigured value degrades to "no limit" rather than to an expired deadline: the check is
|
|
99
|
+
`maxRunMs > 0`, and a non-numeric value arriving through `NSC__AI__MAX_RUN_MS` yields `NaN`, which
|
|
100
|
+
fails that comparison. See `.claude/rules/configurable-features.md` → Numeric Sentinel, Family A.
|
|
101
|
+
|
|
102
|
+
## Troubleshooting
|
|
103
|
+
|
|
104
|
+
**"My final answer used to come back as JSON and now returns prose."** That is the behaviour change
|
|
105
|
+
above, and it is the fix rather than a regression: the final answer was never meant to be JSON. If
|
|
106
|
+
you were parsing it, parse the prose or move the structured part into a tool result, which is what
|
|
107
|
+
tool calling is for.
|
|
108
|
+
|
|
109
|
+
**"An endpoint that supported tools is suddenly using emulated tool calling."** That is the OLD
|
|
110
|
+
defect, and it persisted the wrong flag. Clear the stored capability on the connection so it is
|
|
111
|
+
re-probed with the new budget; detection now records `true` where it previously recorded a false
|
|
112
|
+
negative.
|
|
113
|
+
|
|
114
|
+
## Module Documentation
|
|
115
|
+
|
|
116
|
+
- [AI module README](../src/core/modules/ai/README.md)
|
|
117
|
+
- [AI integration checklist](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
|
|
118
|
+
- [Configurable features](../.claude/rules/configurable-features.md)
|
|
@@ -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.
|
|
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",
|
|
@@ -1901,6 +1901,23 @@ export interface IAi {
|
|
|
1901
1901
|
/** Maximum number of agent-loop iterations (tool round-trips). @default 5 */
|
|
1902
1902
|
maxIterations?: number;
|
|
1903
1903
|
|
|
1904
|
+
/**
|
|
1905
|
+
* Wall-clock ceiling for ONE prompt run, in milliseconds. Checked before each
|
|
1906
|
+
* agent-loop iteration; once exceeded the run stops and answers with whatever it
|
|
1907
|
+
* has (or the translated "no final answer" message).
|
|
1908
|
+
*
|
|
1909
|
+
* Without it the only bound is `maxIterations` multiplied by the connection's
|
|
1910
|
+
* PER-CALL timeout — e.g. 8 iterations at the 120 s default is a request that can
|
|
1911
|
+
* legitimately occupy a socket, its message buffer and a request context for 16
|
|
1912
|
+
* minutes, and compaction can add a further call per iteration on top. Set this
|
|
1913
|
+
* to something a client would actually wait for.
|
|
1914
|
+
*
|
|
1915
|
+
* `0` or omitted disables the check (previous behaviour).
|
|
1916
|
+
*
|
|
1917
|
+
* @default 0
|
|
1918
|
+
*/
|
|
1919
|
+
maxRunMs?: number;
|
|
1920
|
+
|
|
1904
1921
|
/** Maximum characters of a tool-results payload fed back to the model. @default 12000 */
|
|
1905
1922
|
maxToolResultChars?: number;
|
|
1906
1923
|
|
|
@@ -118,11 +118,32 @@ never probed). Detection runs in two complementary ways:
|
|
|
118
118
|
warns — the stored value is never changed. OFF by default because it makes outbound calls to
|
|
119
119
|
the LLM endpoints on every boot; also skipped in the ci/e2e runners.
|
|
120
120
|
|
|
121
|
-
The probe is provider-agnostic best effort
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
The probe is provider-agnostic best effort, and a 2xx alone is never the verdict —
|
|
122
|
+
a backend that does not implement a parameter typically ignores it and answers
|
|
123
|
+
normally, which would persist a `true` it never earns:
|
|
124
|
+
|
|
125
|
+
- **JSON:** `response_format: json_object` is sent and the CONTENT must actually
|
|
126
|
+
parse. A response truncated by the output budget (`finish_reason: 'length'`, empty
|
|
127
|
+
OR partial) proves nothing and is retried once with a larger budget before the
|
|
128
|
+
probe settles on `false`.
|
|
129
|
+
- **Native tools:** a trivial tool with `tool_choice: 'required'` is sent; a
|
|
130
|
+
`tool_calls` result → supported, a `4xx` or a complete answer without tool calls →
|
|
131
|
+
unsupported, a truncation → the same one retry.
|
|
132
|
+
|
|
133
|
+
Both probes run concurrently. Override `OpenAiCompatibleProvider.detectCapabilities()`
|
|
134
|
+
for custom backends, or implement the optional `ILlmProvider.detectCapabilities()` in
|
|
135
|
+
your own provider.
|
|
136
|
+
|
|
137
|
+
> **`supportsJsonResponse` is a CONNECTION flag, but whether an answer must be JSON is
|
|
138
|
+
> a property of the PROMPT.** The JSON output contract is carried only by the
|
|
139
|
+
> `output_contract` / `tool_protocol_emulated` fragments (both `capability: 'emulated'`)
|
|
140
|
+
> and by `plan_protocol`. A **native**-tools run receives none of them and is asked for
|
|
141
|
+
> prose — attaching `response_format` on top is a contradiction the model can only
|
|
142
|
+
> resolve by inventing a shape of its own, which then reaches the user as the answer.
|
|
143
|
+
> The orchestrator therefore decides JSON mode per CALL, not per connection: pass
|
|
144
|
+
> `jsonResponse: false` in `LlmCompletionOptions` from any call whose prompt asks for
|
|
145
|
+
> prose. The option only ever NARROWS — it can never assert JSON mode for a connection
|
|
146
|
+
> whose endpoint was not probed for it.
|
|
126
147
|
|
|
127
148
|
### Backend examples (external, local, CLI)
|
|
128
149
|
|
|
@@ -96,7 +96,22 @@ export class CoreAiController {
|
|
|
96
96
|
res.setHeader('Cache-Control', 'no-cache');
|
|
97
97
|
res.setHeader('Connection', 'keep-alive');
|
|
98
98
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
99
|
+
// Disable proxy-side response buffering (nginx and friends honour this); without
|
|
100
|
+
// it an intermediary can hold the events until the response ends, which defeats
|
|
101
|
+
// the whole point of the stream.
|
|
102
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
99
103
|
res.flushHeaders?.();
|
|
104
|
+
|
|
105
|
+
// A prompt run can stay silent for a long time — a multi-step turn measured
|
|
106
|
+
// 30-40 s, and `ai.maxRunMs` allows up to two minutes. Many proxies close an
|
|
107
|
+
// idle connection at 60 s, which the client then sees as a turn that silently
|
|
108
|
+
// vanished. Comment frames are ignored by every SSE client and keep the
|
|
109
|
+
// connection observably alive.
|
|
110
|
+
const heartbeat = setInterval(() => {
|
|
111
|
+
res.write(': keep-alive\n\n');
|
|
112
|
+
}, 15_000);
|
|
113
|
+
heartbeat.unref?.();
|
|
114
|
+
|
|
100
115
|
try {
|
|
101
116
|
for await (const event of this.aiService.promptStream(input, serviceOptions)) {
|
|
102
117
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
@@ -104,6 +119,7 @@ export class CoreAiController {
|
|
|
104
119
|
} catch (err) {
|
|
105
120
|
res.write(`data: ${JSON.stringify({ message: (err as Error).message, type: 'error' })}\n\n`);
|
|
106
121
|
} finally {
|
|
122
|
+
clearInterval(heartbeat);
|
|
107
123
|
res.end();
|
|
108
124
|
}
|
|
109
125
|
}
|
|
@@ -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
|
|
|
@@ -108,6 +119,21 @@ export interface LlmUsage {
|
|
|
108
119
|
* when omitted.
|
|
109
120
|
*/
|
|
110
121
|
export interface LlmCompletionOptions {
|
|
122
|
+
/**
|
|
123
|
+
* Set `false` to suppress structured-JSON mode for THIS call even though the
|
|
124
|
+
* connection advertises `supportsJsonResponse`.
|
|
125
|
+
*
|
|
126
|
+
* Narrowing only — it can never switch JSON mode ON for a connection whose
|
|
127
|
+
* endpoint was not probed for it, because the flag is measured per connection and
|
|
128
|
+
* asserting it elsewhere is what produces a 4xx nobody expected.
|
|
129
|
+
*
|
|
130
|
+
* It exists because `supportsJsonResponse` is CONNECTION state while whether an
|
|
131
|
+
* answer must be JSON is a property of the PROMPT. A caller that asks for prose —
|
|
132
|
+
* a summary, a native-tools chat turn, a plan summary — must be able to say so
|
|
133
|
+
* without rebuilding the connection object around the flag.
|
|
134
|
+
*/
|
|
135
|
+
jsonResponse?: boolean;
|
|
136
|
+
|
|
111
137
|
/** Maximum number of tokens to generate. */
|
|
112
138
|
maxTokens?: number;
|
|
113
139
|
|
|
@@ -125,6 +151,19 @@ export interface LlmCompletionOptions {
|
|
|
125
151
|
* Normalized response of a single LLM completion.
|
|
126
152
|
*/
|
|
127
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
|
+
|
|
128
167
|
/** Raw provider payload (for debugging/audit, never sent to clients). */
|
|
129
168
|
raw?: unknown;
|
|
130
169
|
|
|
@@ -89,9 +89,18 @@ export class CoreAiPrompt extends CorePersistenceModel {
|
|
|
89
89
|
|
|
90
90
|
/** Tenant id when scope = 'tenant' (set from the creator's tenant at create time). */
|
|
91
91
|
@UnifiedField({
|
|
92
|
+
// No `index: true`: declaring the `tenantId` PATH is what activates
|
|
93
|
+
// `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
|
|
94
|
+
// itself. Declaring it here as well makes Mongoose log
|
|
95
|
+
// "Duplicate schema index on {"tenantId":1}" on every boot.
|
|
96
|
+
//
|
|
97
|
+
// The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
|
|
98
|
+
// `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
|
|
99
|
+
// and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
|
|
100
|
+
// the model would lose its tenant filtering entirely.
|
|
92
101
|
description: 'Tenant id (when scope = "tenant")',
|
|
93
102
|
isOptional: true,
|
|
94
|
-
mongoose: {
|
|
103
|
+
mongoose: { type: String },
|
|
95
104
|
roles: RoleEnum.S_USER,
|
|
96
105
|
})
|
|
97
106
|
tenantId?: string = undefined;
|
|
@@ -133,9 +133,18 @@ export class CoreAiSlot extends CorePersistenceModel {
|
|
|
133
133
|
* slot is effectively system-wide.
|
|
134
134
|
*/
|
|
135
135
|
@UnifiedField({
|
|
136
|
+
// No `index: true`: declaring the `tenantId` PATH is what activates
|
|
137
|
+
// `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
|
|
138
|
+
// itself. Declaring it here as well makes Mongoose log
|
|
139
|
+
// "Duplicate schema index on {"tenantId":1}" on every boot.
|
|
140
|
+
//
|
|
141
|
+
// The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
|
|
142
|
+
// `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
|
|
143
|
+
// and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
|
|
144
|
+
// the model would lose its tenant filtering entirely.
|
|
136
145
|
description: 'Tenant id the slot applies to (auto-set; undefined = system-wide)',
|
|
137
146
|
isOptional: true,
|
|
138
|
-
mongoose: {
|
|
147
|
+
mongoose: { type: String },
|
|
139
148
|
roles: RoleEnum.ADMIN,
|
|
140
149
|
})
|
|
141
150
|
tenantId?: string = undefined;
|