@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.
Files changed (29) hide show
  1. package/.claude/rules/configurable-features.md +2 -0
  2. package/.claude/rules/testing.md +3 -3
  3. package/FRAMEWORK-API.md +2 -1
  4. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  5. package/dist/core/modules/ai/core-ai.controller.js +6 -0
  6. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  7. package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +3 -0
  8. package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
  9. package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
  10. package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
  11. package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
  12. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +9 -1
  13. package/dist/core/modules/ai/providers/openai-compatible.provider.js +130 -12
  14. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  15. package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
  16. package/dist/core/modules/ai/services/core-ai.service.js +131 -13
  17. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  18. package/dist/tsconfig.build.tsbuildinfo +1 -1
  19. package/migration-guides/11.40.0-to-11.41.0.md +118 -0
  20. package/migration-guides/11.41.0-to-11.41.1.md +114 -0
  21. package/package.json +1 -1
  22. package/src/core/common/interfaces/server-options.interface.ts +17 -0
  23. package/src/core/modules/ai/README.md +26 -5
  24. package/src/core/modules/ai/core-ai.controller.ts +16 -0
  25. package/src/core/modules/ai/interfaces/llm-provider.interface.ts +39 -0
  26. package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
  27. package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
  28. package/src/core/modules/ai/providers/openai-compatible.provider.ts +380 -17
  29. package/src/core/modules/ai/services/core-ai.service.ts +333 -23
@@ -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
 
@@ -77,11 +94,109 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
77
94
  type: 'function',
78
95
  }));
79
96
  }
80
- if (this.capabilities.jsonResponse) {
97
+ // A per-request `model` overrides the connection's — but the capability flags
98
+ // were probed against `connection.model` and persisted per CONNECTION, never
99
+ // per model. Applying them to a different model asserts something that was
100
+ // never measured: the endpoint may reject `response_format` for it, and the
101
+ // caller sees a transport error where it expected an answer. Fall back to the
102
+ // safe subset (prompt-driven JSON + defensive parsing) whenever the model the
103
+ // request actually targets is not the one the probe ran against.
104
+ // `options.jsonResponse === false` narrows this off for a single call — the way a
105
+ // caller whose PROMPT asks for prose says so. It can only ever narrow: the flag
106
+ // was measured against this connection, so no option may assert it where the
107
+ // probe never ran.
108
+ if (this.capabilities.jsonResponse && options?.jsonResponse !== false && body.model === this.connection.model) {
81
109
  body.response_format = { type: 'json_object' };
82
110
  }
83
111
 
84
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> {
85
200
  let response: Response;
86
201
  try {
87
202
  response = await fetch(url, {
@@ -106,26 +221,64 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
106
221
  }
107
222
 
108
223
  const result = (await response.json()) as {
109
- choices?: { message?: { content?: string; tool_calls?: any[] } }[];
110
- 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
+ };
111
231
  };
112
232
 
113
- const choice = result.choices?.[0]?.message;
114
- const text = choice?.content ?? '';
115
- 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;
116
238
 
117
239
  return {
240
+ finishReason: choice?.finish_reason,
118
241
  raw: result,
119
242
  text,
120
243
  toolCalls: nativeToolCalls,
121
244
  usage: {
122
245
  completionTokens: result.usage?.completion_tokens,
123
246
  promptTokens: result.usage?.prompt_tokens,
247
+ reasoningTokens: result.usage?.completion_tokens_details?.reasoning_tokens,
124
248
  totalTokens: result.usage?.total_tokens,
125
249
  },
126
250
  };
127
251
  }
128
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
+
129
282
  /**
130
283
  * Optional SSRF hardening: when `ai.allowedBaseUrlHosts` is configured (non-empty),
131
284
  * only allow requests to those hosts (matched by `host` incl. port, or bare
@@ -162,6 +315,50 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
162
315
  }
163
316
  }
164
317
 
318
+ /**
319
+ * Output budget for the native-tool probe.
320
+ *
321
+ * A reasoning model spends output tokens on its thinking phase BEFORE it emits
322
+ * `tool_calls`. With a budget of a few tokens the endpoint answers `200` with
323
+ * `finish_reason: 'length'` and no tool call at all — which the probe used to read
324
+ * as "native tools unsupported", persisting a false negative that is never
325
+ * re-probed and degrades the assistant to emulated tool calling for good.
326
+ *
327
+ * Measured against an OpenAI-compatible hosting endpoint on 2026-07-25
328
+ * (`tool_choice: 'required'` plus a trivial `ping` tool, varying only
329
+ * `max_tokens`):
330
+ *
331
+ * | model | 8 | 64 | 256 |
332
+ * |--------------------------|----|----|-----|
333
+ * | Ministral-3-14B-Instruct | ✅ | ✅ | ✅ |
334
+ * | Mistral-Medium-3.5-128B | ✅ | ✅ | ✅ |
335
+ * | gpt-oss-120b | ❌ | ✅ | ✅ |
336
+ * | Qwen3.5-122B-A10B-FP8 | ❌ | ✅ | ✅ |
337
+ * | Qwen3.6-35B-A3B-FP8 | ❌ | ❌ | ✅ |
338
+ *
339
+ * 256 covers every model tested and costs a few hundred tokens ONCE per
340
+ * connection, which is nothing against the cost of the wrong flag.
341
+ */
342
+ protected static readonly NATIVE_TOOL_PROBE_MAX_TOKENS = 256;
343
+
344
+ /**
345
+ * Second and FINAL budget for the native-tool probe, used only when the first
346
+ * attempt came back truncated.
347
+ *
348
+ * The retry has to be bounded, and the bound has to live here. An inconclusive
349
+ * result leaves the capability `undefined`, `detectAndPersistCapabilities` only
350
+ * persists booleans, and the orchestrator re-runs detection whenever a flag is
351
+ * undefined — so "just leave it undetected and try again later" would fire one
352
+ * extra upstream completion before EVERY user prompt, ahead of the rate limiter
353
+ * and outside any budget accounting. Two attempts, then a definite answer.
354
+ *
355
+ * Returning `false` after a model failed to emit a tool call within 1024 output
356
+ * tokens is not the false negative this fix exists to prevent: a model that needs
357
+ * more than that before its first tool call cannot drive an agent loop usefully
358
+ * anyway, and emulated tool calling is the correct fallback for it.
359
+ */
360
+ protected static readonly NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY = 1024;
361
+
165
362
  /**
166
363
  * The configured egress allowlist as a lowercase array, whatever shape it has.
167
364
  *
@@ -221,27 +418,162 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
221
418
  /**
222
419
  * Probe the backend to auto-detect capabilities for flags the connection left
223
420
  * undefined. Explicit flags are authoritative and are NOT probed. Best effort:
224
- * - JSON: send `response_format: json_object`; 2xx true, 4xx → false.
421
+ * - JSON: send `response_format: json_object`; 2xx AND content that actually
422
+ * parses as JSON → true. A 2xx alone is not evidence — a backend that does not
423
+ * implement `response_format` simply ignores the field and answers normally.
225
424
  * - 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).
425
+ * `tool_calls` result → true. A backend that answers fully but silently ignores
426
+ * the tools returns no tool_calls and IS a real negative. A response truncated
427
+ * by the token budget (`finish_reason: 'length'` without tool_calls) proves
428
+ * nothing, so it is retried ONCE with
429
+ * {@link NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY}; a second truncation resolves to
430
+ * `false`.
431
+ *
432
+ * This method always returns a definite boolean for a flag it probed. That is
433
+ * deliberate: an `undefined` result is not persisted by
434
+ * `detectAndPersistCapabilities`, and the orchestrator re-runs detection whenever
435
+ * a flag is undefined — so an endpoint that keeps truncating would trigger one
436
+ * extra upstream completion before every user prompt, ahead of the rate limiter
437
+ * and outside budget accounting.
228
438
  *
229
439
  * Throws on a transport error so callers can treat the connection as undetected
230
440
  * (and retry later) rather than persisting a wrong value.
231
441
  */
232
442
  async detectCapabilities(): Promise<{ jsonResponse?: boolean; nativeTools?: boolean }> {
443
+ const needsJson = this.connection.supportsJsonResponse === undefined;
444
+ const needsTools = this.connection.supportsNativeTools === undefined;
445
+ // Independent upstream calls, so run them together. This matters more since both
446
+ // probes gained a truncation retry: sequentially, a fresh connection can now cost
447
+ // FOUR round trips before the user's own completion starts — and lazy detection
448
+ // sits inline on the interactive prompt path.
449
+ const [jsonResponse, nativeTools] = await Promise.all([
450
+ needsJson ? this.probeJsonResponse() : Promise.resolve(undefined),
451
+ needsTools ? this.probeNativeTools() : Promise.resolve(undefined),
452
+ ]);
233
453
  const result: { jsonResponse?: boolean; nativeTools?: boolean } = {};
234
- if (this.connection.supportsJsonResponse === undefined) {
454
+ if (needsJson) {
455
+ result.jsonResponse = jsonResponse;
456
+ }
457
+ if (needsTools) {
458
+ result.nativeTools = nativeTools;
459
+ }
460
+ return result;
461
+ }
462
+
463
+ /**
464
+ * Probe structured-JSON support.
465
+ *
466
+ * A 2xx alone is NOT evidence: a backend that does not implement
467
+ * `response_format` typically ignores the unknown field and answers normally, so
468
+ * trusting the status code alone persists `supportsJsonResponse: true` for an
469
+ * endpoint that never honours it — after which `chat()` sends `response_format`
470
+ * on every single call. Like the native-tool flag this is written once and never
471
+ * re-probed, so the wrong value is permanent.
472
+ *
473
+ * The response content must therefore actually parse as JSON. The budget matches
474
+ * the tool probe for the same reason: a reasoning model needs room to get past
475
+ * its thinking phase before it emits anything parseable — INCLUDING the tool
476
+ * probe's retry on truncation, which this probe needs just as badly.
477
+ *
478
+ * Measured against an OpenAI-compatible hosting endpoint on 2026-09-03 (this
479
+ * same probe, varying only `max_tokens`; the number is the completion tokens the
480
+ * model actually spent):
481
+ *
482
+ * | model | 256 | 1024 |
483
+ * |-------------------------------|--------------|--------|
484
+ * | Ministral-3-14B-Instruct-2512 | ✅ 9 | — |
485
+ * | gpt-oss-120b | ✅ 88 | — |
486
+ * | Qwen3.6-35B-A3B-FP8 | ✅ 202 | — |
487
+ * | Mistral-Medium-3.5-128B | ❌ truncated | ✅ 878 |
488
+ * | Qwen3.5-122B-A10B-FP8 | ❌ truncated | ✅ 365 |
489
+ *
490
+ * TWO of five need the retry — so without it the probe records "structured JSON
491
+ * unsupported" for an endpoint that demonstrably supports it.
492
+ *
493
+ * That false negative costs on two different paths, and the expensive one is the
494
+ * quiet one. `detectAndPersistCapabilities` WRITES what the probe returns, and a
495
+ * written flag is authoritative and never re-probed — so a fresh connection whose
496
+ * flags are unset is pinned to the wrong `false` for good, silently falling back
497
+ * to prompt-driven JSON. The loud path is the `ai.capabilityDriftCheck` boot
498
+ * warning (`supportsJsonResponse declared true but the endpoint reports false`),
499
+ * which merely reads as endpoint drift and sends the next reader hunting for one
500
+ * — which is how this was found. Note the warning fires only where that
501
+ * opt-in check is enabled, while the persisted flag is wrong everywhere.
502
+ *
503
+ * A COMPLETE answer that merely is not JSON stays a real negative: the endpoint
504
+ * ignored `response_format`, and a larger budget cannot change that.
505
+ */
506
+ protected async probeJsonResponse(): Promise<boolean> {
507
+ const budgets = [
508
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS,
509
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY,
510
+ ];
511
+
512
+ for (const [attempt, maxTokens] of budgets.entries()) {
235
513
  const res = await this.probe({
236
- max_tokens: 8,
514
+ max_tokens: maxTokens,
237
515
  messages: [{ content: 'Reply with the JSON object {"ok":true}.', role: 'user' }],
238
516
  response_format: { type: 'json_object' },
239
517
  });
240
- result.jsonResponse = res.ok;
518
+ if (!res.ok) {
519
+ return false;
520
+ }
521
+ const choice = res.json?.choices?.[0];
522
+ const content = choice?.message?.content;
523
+ const truncated = choice?.finish_reason === 'length';
524
+ if (typeof content === 'string' && content.trim()) {
525
+ try {
526
+ JSON.parse(content);
527
+ return true;
528
+ } catch {
529
+ // A parse failure is only a REAL negative when the model finished on its
530
+ // own terms. Truncation is the other reason JSON does not parse, and it
531
+ // arrives in two shapes depending on how the model emits: a reasoning
532
+ // model buffers behind its thinking phase and returns EMPTY content, while
533
+ // one that streams directly returns a partial body like `{"ok":tr`. Both
534
+ // are `finish_reason: 'length'`, and classifying the partial one here
535
+ // instead of retrying reaches the exact false negative this ladder exists
536
+ // to remove — just through the other door.
537
+ if (!truncated) {
538
+ this.logger.warn(
539
+ `JSON-response probe for model "${this.connection.model}" returned non-JSON content — ` +
540
+ 'recording structured JSON as unsupported',
541
+ );
542
+ return false;
543
+ }
544
+ }
545
+ } else if (!truncated) {
546
+ // Empty content that finished on its own terms is a real negative.
547
+ return false;
548
+ }
549
+
550
+ const isLastAttempt = attempt === budgets.length - 1;
551
+ this.logger.warn(
552
+ `JSON-response probe for model "${this.connection.model}" was truncated (finish_reason=length) at ` +
553
+ `max_tokens=${maxTokens}` +
554
+ (isLastAttempt
555
+ ? ' on the final attempt — recording structured JSON as unsupported; a model that cannot emit a ' +
556
+ 'trivial JSON object within a usable output budget is better served by the prompt-driven fallback'
557
+ : ' — retrying once with a larger budget'),
558
+ );
241
559
  }
242
- if (this.connection.supportsNativeTools === undefined) {
560
+
561
+ return false;
562
+ }
563
+
564
+ /**
565
+ * Run the native-tool probe, escalating the output budget once on truncation.
566
+ * Extracted so the retry policy is overridable and testable on its own.
567
+ */
568
+ protected async probeNativeTools(): Promise<boolean> {
569
+ const budgets = [
570
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS,
571
+ OpenAiCompatibleProvider.NATIVE_TOOL_PROBE_MAX_TOKENS_RETRY,
572
+ ];
573
+
574
+ for (const [attempt, maxTokens] of budgets.entries()) {
243
575
  const res = await this.probe({
244
- max_tokens: 8,
576
+ max_tokens: maxTokens,
245
577
  messages: [{ content: 'Call the ping tool.', role: 'user' }],
246
578
  tool_choice: 'required',
247
579
  tools: [
@@ -255,9 +587,29 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
255
587
  },
256
588
  ],
257
589
  });
258
- result.nativeTools = res.ok && !!res.json?.choices?.[0]?.message?.tool_calls?.length;
590
+ const choice = res.json?.choices?.[0];
591
+
592
+ if (res.ok && choice?.message?.tool_calls?.length) {
593
+ return true;
594
+ }
595
+ // A non-2xx, or a complete answer without tool calls, is a REAL negative —
596
+ // retrying with a bigger budget would not change it.
597
+ if (!res.ok || choice?.finish_reason !== 'length') {
598
+ return false;
599
+ }
600
+
601
+ const isLastAttempt = attempt === budgets.length - 1;
602
+ this.logger.warn(
603
+ `Native-tool probe for model "${this.connection.model}" was truncated (finish_reason=length) at ` +
604
+ `max_tokens=${maxTokens}` +
605
+ (isLastAttempt
606
+ ? ' on the final attempt — recording native tools as unsupported; the model does not reach a tool ' +
607
+ 'call within a usable output budget, so emulated tool calling is the correct fallback'
608
+ : ' — retrying once with a larger budget'),
609
+ );
259
610
  }
260
- return result;
611
+
612
+ return false;
261
613
  }
262
614
 
263
615
  /**
@@ -306,7 +658,18 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
306
658
  128_000,
307
659
  ['gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'o1', 'o3', 'gpt-oss', 'mistral-large', 'mistral-small3', 'command-r'],
308
660
  ],
309
- [131_072, ['qwen2.5', 'qwen3', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama3.3']],
661
+ // Two DIFFERENT gaps, both closed here (measured 2026-07-25 against an
662
+ // OpenAI-compatible hosting endpoint; both models verified to accept >=163k
663
+ // prompt tokens):
664
+ // - `mistral-medium` DID match the generic `mistral` -> 32768 entry below,
665
+ // capping a 256k model at an eighth of its window. It must therefore be
666
+ // matched before it -- this bucket is evaluated first.
667
+ // - `ministral` matched NOTHING at all: "ministral" does not contain the
668
+ // substring "mistral" (m-i-n-i-s-t-r-a-l), so it fell through the whole
669
+ // table to the conservative 8192 default.
670
+ // Pinned one power of two below the verified capacity so the orchestrator
671
+ // trims before the endpoint rejects.
672
+ [131_072, ['qwen2.5', 'qwen3', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama3.3', 'ministral', 'mistral-medium']],
310
673
  [65_536, ['mixtral']],
311
674
  [32_768, ['qwen2', 'mistral', 'gemma2', 'gemma-2']],
312
675
  [16_385, ['gpt-3.5']],