@lenne.tech/nest-server 11.40.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 (27) hide show
  1. package/.claude/rules/configurable-features.md +2 -0
  2. package/FRAMEWORK-API.md +2 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  4. package/dist/core/modules/ai/core-ai.controller.js +6 -0
  5. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  6. package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +1 -0
  7. package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
  8. package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
  9. package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
  10. package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
  11. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +4 -0
  12. package/dist/core/modules/ai/providers/openai-compatible.provider.js +78 -9
  13. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  14. package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
  15. package/dist/core/modules/ai/services/core-ai.service.js +131 -13
  16. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  17. package/dist/tsconfig.build.tsbuildinfo +1 -1
  18. package/migration-guides/11.40.0-to-11.41.0.md +118 -0
  19. package/package.json +1 -1
  20. package/src/core/common/interfaces/server-options.interface.ts +17 -0
  21. package/src/core/modules/ai/README.md +26 -5
  22. package/src/core/modules/ai/core-ai.controller.ts +16 -0
  23. package/src/core/modules/ai/interfaces/llm-provider.interface.ts +15 -0
  24. package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
  25. package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
  26. package/src/core/modules/ai/providers/openai-compatible.provider.ts +233 -12
  27. 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.40.0",
3
+ "version": "11.41.0",
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: `response_format: json_object` is sent
122
- (2xx JSON supported); a trivial tool with `tool_choice: 'required'` is sent (2xx
123
- returning a `tool_calls` result → native tools supported; a `4xx` or a silent ignore
124
- → unsupported). Override `OpenAiCompatibleProvider.detectCapabilities()` for custom
125
- backends, or implement the optional `ILlmProvider.detectCapabilities()` in your own provider.
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
  }
@@ -108,6 +108,21 @@ export interface LlmUsage {
108
108
  * when omitted.
109
109
  */
110
110
  export interface LlmCompletionOptions {
111
+ /**
112
+ * Set `false` to suppress structured-JSON mode for THIS call even though the
113
+ * connection advertises `supportsJsonResponse`.
114
+ *
115
+ * Narrowing only — it can never switch JSON mode ON for a connection whose
116
+ * endpoint was not probed for it, because the flag is measured per connection and
117
+ * asserting it elsewhere is what produces a 4xx nobody expected.
118
+ *
119
+ * It exists because `supportsJsonResponse` is CONNECTION state while whether an
120
+ * answer must be JSON is a property of the PROMPT. A caller that asks for prose —
121
+ * a summary, a native-tools chat turn, a plan summary — must be able to say so
122
+ * without rebuilding the connection object around the flag.
123
+ */
124
+ jsonResponse?: boolean;
125
+
111
126
  /** Maximum number of tokens to generate. */
112
127
  maxTokens?: number;
113
128
 
@@ -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: { index: true },
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: { index: true },
147
+ mongoose: { type: String },
139
148
  roles: RoleEnum.ADMIN,
140
149
  })
141
150
  tenantId?: string = undefined;
@@ -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
 
@@ -162,6 +173,50 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
162
173
  }
163
174
  }
164
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
+
165
220
  /**
166
221
  * The configured egress allowlist as a lowercase array, whatever shape it has.
167
222
  *
@@ -221,27 +276,162 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
221
276
  /**
222
277
  * Probe the backend to auto-detect capabilities for flags the connection left
223
278
  * undefined. Explicit flags are authoritative and are NOT probed. Best effort:
224
- * - 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.
225
282
  * - Native tools: send a trivial tool with `tool_choice: 'required'`; 2xx WITH a
226
- * `tool_calls` result → true, otherwise false (a backend that silently ignores
227
- * 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.
228
296
  *
229
297
  * Throws on a transport error so callers can treat the connection as undetected
230
298
  * (and retry later) rather than persisting a wrong value.
231
299
  */
232
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
+ ]);
233
311
  const result: { jsonResponse?: boolean; nativeTools?: boolean } = {};
234
- 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()) {
235
371
  const res = await this.probe({
236
- max_tokens: 8,
372
+ max_tokens: maxTokens,
237
373
  messages: [{ content: 'Reply with the JSON object {"ok":true}.', role: 'user' }],
238
374
  response_format: { type: 'json_object' },
239
375
  });
240
- 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
+ );
241
417
  }
242
- 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()) {
243
433
  const res = await this.probe({
244
- max_tokens: 8,
434
+ max_tokens: maxTokens,
245
435
  messages: [{ content: 'Call the ping tool.', role: 'user' }],
246
436
  tool_choice: 'required',
247
437
  tools: [
@@ -255,9 +445,29 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
255
445
  },
256
446
  ],
257
447
  });
258
- 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
+ );
259
468
  }
260
- return result;
469
+
470
+ return false;
261
471
  }
262
472
 
263
473
  /**
@@ -306,7 +516,18 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
306
516
  128_000,
307
517
  ['gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'o1', 'o3', 'gpt-oss', 'mistral-large', 'mistral-small3', 'command-r'],
308
518
  ],
309
- [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']],
310
531
  [65_536, ['mixtral']],
311
532
  [32_768, ['qwen2', 'mistral', 'gemma2', 'gemma-2']],
312
533
  [16_385, ['gpt-3.5']],