@lenne.tech/nest-server 11.39.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.
- package/.claude/rules/configurable-features.md +3 -0
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +51 -2
- package/.claude/rules/testing.md +182 -3
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
- 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 +1 -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 +6 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +106 -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.39.0-to-11.40.0.md +186 -0
- package/migration-guides/11.40.0-to-11.41.0.md +118 -0
- package/package.json +5 -4
- package/src/core/common/interfaces/server-options.interface.ts +34 -1
- package/src/core/modules/ai/README.md +59 -5
- package/src/core/modules/ai/core-ai.controller.ts +16 -0
- package/src/core/modules/ai/interfaces/llm-provider.interface.ts +15 -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 +309 -15
- package/src/core/modules/ai/services/core-ai.service.ts +333 -23
|
@@ -77,7 +77,18 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
77
77
|
type: 'function',
|
|
78
78
|
}));
|
|
79
79
|
}
|
|
80
|
-
|
|
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
|
|
|
@@ -134,8 +145,8 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
134
145
|
* admin, not an end-user input.
|
|
135
146
|
*/
|
|
136
147
|
protected assertBaseUrlAllowed(url: string): void {
|
|
137
|
-
const allowedHosts =
|
|
138
|
-
if (!
|
|
148
|
+
const allowedHosts = this.resolveAllowedBaseUrlHosts();
|
|
149
|
+
if (!allowedHosts.length) {
|
|
139
150
|
return;
|
|
140
151
|
}
|
|
141
152
|
let parsed: URL;
|
|
@@ -144,7 +155,17 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
144
155
|
} catch {
|
|
145
156
|
throw new ServiceUnavailableException(ErrorCode.AI_CONNECTION_INVALID_URL);
|
|
146
157
|
}
|
|
147
|
-
|
|
158
|
+
// A hostname entry stays host-wide (any port); the extra candidates only make an
|
|
159
|
+
// operator who was MORE explicit than necessary succeed rather than fail. `URL.host`
|
|
160
|
+
// omits the default port, so a conscientious `llm.example.com:443` entry would
|
|
161
|
+
// otherwise never match `https://llm.example.com/` — a lockout whose only symptom is
|
|
162
|
+
// a WARN and "the AI stopped working". Nothing here widens the set of reachable hosts.
|
|
163
|
+
const defaultPort = parsed.protocol === 'https:' ? '443' : parsed.protocol === 'http:' ? '80' : '';
|
|
164
|
+
const candidates = [parsed.host, parsed.hostname];
|
|
165
|
+
if (defaultPort && parsed.host === parsed.hostname) {
|
|
166
|
+
candidates.push(`${parsed.hostname}:${defaultPort}`);
|
|
167
|
+
}
|
|
168
|
+
if (!candidates.map((candidate) => this.normaliseHostEntry(candidate)).some((c) => allowedHosts.includes(c))) {
|
|
148
169
|
this.logger.warn(
|
|
149
170
|
`AI connection "${this.connection.name}" host "${parsed.host}" is not in ai.allowedBaseUrlHosts`,
|
|
150
171
|
);
|
|
@@ -152,30 +173,265 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
152
173
|
}
|
|
153
174
|
}
|
|
154
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
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The configured egress allowlist as a lowercase array, whatever shape it has.
|
|
222
|
+
*
|
|
223
|
+
* A STRING is split as CSV rather than rejected, because `ai.allowedBaseUrlHosts`
|
|
224
|
+
* is reachable through the framework's own `NSC__AI__ALLOWED_BASE_URL_HOSTS`
|
|
225
|
+
* environment mapping: `getEnvironmentObject()` turns that variable into
|
|
226
|
+
* `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns the
|
|
227
|
+
* scalar straight over the configured array. A bare `!Array.isArray(...) -> return`
|
|
228
|
+
* then reads it as "no allowlist configured" and skips the check entirely — so an
|
|
229
|
+
* operator using the canonical `NSC__` spelling silently disables SSRF egress
|
|
230
|
+
* control, with no log line and no error. A malformed security setting must be
|
|
231
|
+
* interpreted or fail CLOSED, never fail open.
|
|
232
|
+
*
|
|
233
|
+
* Entries are lowercased because `URL.host` / `URL.hostname` always are; a
|
|
234
|
+
* differently-cased entry would otherwise fail closed for no stated reason, and
|
|
235
|
+
* the only symptom would be a WARN log plus "the AI stopped working".
|
|
236
|
+
*
|
|
237
|
+
* A value that is NEITHER an array nor a string carries no hostnames and cannot be
|
|
238
|
+
* interpreted — `NSC__AI__ALLOWED_BASE_URL_HOSTS=0` coerces to a number, and
|
|
239
|
+
* `NEST_SERVER_CONFIG` can deliver an object. Returning an empty list there is the
|
|
240
|
+
* only honest answer, but it reopens egress while the operator believes the control
|
|
241
|
+
* is on, so it is LOGGED every time rather than passed over in silence. That is the
|
|
242
|
+
* difference between this and the documented unset-is-permissive default: unset is a
|
|
243
|
+
* decision, a malformed value is an accident nobody is told about.
|
|
244
|
+
*/
|
|
245
|
+
protected resolveAllowedBaseUrlHosts(): string[] {
|
|
246
|
+
const configured = ConfigService.get<unknown>('ai.allowedBaseUrlHosts');
|
|
247
|
+
if (configured === undefined || configured === null) {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
const entries = typeof configured === 'string' ? configured.split(',') : configured;
|
|
251
|
+
if (!Array.isArray(entries)) {
|
|
252
|
+
this.logger.error(
|
|
253
|
+
`ai.allowedBaseUrlHosts is a ${typeof configured} and carries no hostnames — the SSRF egress ` +
|
|
254
|
+
'allowlist is NOT active. Use an array or a comma-separated string.',
|
|
255
|
+
);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
return entries.map((host) => this.normaliseHostEntry(String(host))).filter(Boolean);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Lowercase, trim, and drop a fully-qualifying trailing dot.
|
|
263
|
+
*
|
|
264
|
+
* Applied to BOTH the allowlist entry and the URL being checked, so neither side can
|
|
265
|
+
* win by spelling the same DNS name differently. `llm.example.com.` and
|
|
266
|
+
* `llm.example.com` resolve identically, so treating them as different hosts only ever
|
|
267
|
+
* produced a confusing refusal — never protection.
|
|
268
|
+
*/
|
|
269
|
+
protected normaliseHostEntry(value: string): string {
|
|
270
|
+
return value
|
|
271
|
+
.trim()
|
|
272
|
+
.toLowerCase()
|
|
273
|
+
.replace(/\.(?=$|:)/, '');
|
|
274
|
+
}
|
|
275
|
+
|
|
155
276
|
/**
|
|
156
277
|
* Probe the backend to auto-detect capabilities for flags the connection left
|
|
157
278
|
* undefined. Explicit flags are authoritative and are NOT probed. Best effort:
|
|
158
|
-
* - JSON: send `response_format: json_object`; 2xx
|
|
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.
|
|
159
282
|
* - Native tools: send a trivial tool with `tool_choice: 'required'`; 2xx WITH a
|
|
160
|
-
* `tool_calls` result → true
|
|
161
|
-
* tools returns no tool_calls and
|
|
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.
|
|
162
296
|
*
|
|
163
297
|
* Throws on a transport error so callers can treat the connection as undetected
|
|
164
298
|
* (and retry later) rather than persisting a wrong value.
|
|
165
299
|
*/
|
|
166
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
|
+
]);
|
|
167
311
|
const result: { jsonResponse?: boolean; nativeTools?: boolean } = {};
|
|
168
|
-
if (
|
|
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()) {
|
|
169
371
|
const res = await this.probe({
|
|
170
|
-
max_tokens:
|
|
372
|
+
max_tokens: maxTokens,
|
|
171
373
|
messages: [{ content: 'Reply with the JSON object {"ok":true}.', role: 'user' }],
|
|
172
374
|
response_format: { type: 'json_object' },
|
|
173
375
|
});
|
|
174
|
-
|
|
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
|
+
);
|
|
175
417
|
}
|
|
176
|
-
|
|
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()) {
|
|
177
433
|
const res = await this.probe({
|
|
178
|
-
max_tokens:
|
|
434
|
+
max_tokens: maxTokens,
|
|
179
435
|
messages: [{ content: 'Call the ping tool.', role: 'user' }],
|
|
180
436
|
tool_choice: 'required',
|
|
181
437
|
tools: [
|
|
@@ -189,9 +445,29 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
189
445
|
},
|
|
190
446
|
],
|
|
191
447
|
});
|
|
192
|
-
|
|
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
|
+
);
|
|
193
468
|
}
|
|
194
|
-
|
|
469
|
+
|
|
470
|
+
return false;
|
|
195
471
|
}
|
|
196
472
|
|
|
197
473
|
/**
|
|
@@ -240,7 +516,18 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
240
516
|
128_000,
|
|
241
517
|
['gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'o1', 'o3', 'gpt-oss', 'mistral-large', 'mistral-small3', 'command-r'],
|
|
242
518
|
],
|
|
243
|
-
|
|
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']],
|
|
244
531
|
[65_536, ['mixtral']],
|
|
245
532
|
[32_768, ['qwen2', 'mistral', 'gemma2', 'gemma-2']],
|
|
246
533
|
[16_385, ['gpt-3.5']],
|
|
@@ -263,6 +550,13 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
|
|
|
263
550
|
if (!base.startsWith('http')) {
|
|
264
551
|
return undefined;
|
|
265
552
|
}
|
|
553
|
+
// The third outbound path from the same admin-controlled baseUrl, and the one the
|
|
554
|
+
// allowlist used to miss. It is NOT admin-only in practice: CoreAiService calls
|
|
555
|
+
// detectAndPersistCapabilities() on an ordinary user prompt whenever contextWindow is
|
|
556
|
+
// undefined, and it runs BEFORE checkRateLimit(). A guard applied to two of three
|
|
557
|
+
// egress paths is not a guard. Throwing is right here — detectContextWindow() already
|
|
558
|
+
// wraps this call, so a refusal degrades to "context window unknown".
|
|
559
|
+
this.assertBaseUrlAllowed(`${base}/api/show`);
|
|
266
560
|
const response = await fetch(`${base}/api/show`, {
|
|
267
561
|
body: JSON.stringify({ name: this.connection.model }),
|
|
268
562
|
headers: { 'Content-Type': 'application/json' },
|