@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
|
@@ -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
|
-
|
|
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?: {
|
|
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]
|
|
114
|
-
const text = choice?.content ?? '';
|
|
115
|
-
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;
|
|
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
|
|
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
|
|
227
|
-
* tools returns no tool_calls and
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']],
|