@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.
- package/.claude/rules/configurable-features.md +2 -0
- 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 +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 +4 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +78 -9
- 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/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 +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 +233 -12
- 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.
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
The probe is provider-agnostic best effort, and a 2xx alone is never the verdict —
|
|
122
|
+
a backend that does not implement a parameter typically ignores it and answers
|
|
123
|
+
normally, which would persist a `true` it never earns:
|
|
124
|
+
|
|
125
|
+
- **JSON:** `response_format: json_object` is sent and the CONTENT must actually
|
|
126
|
+
parse. A response truncated by the output budget (`finish_reason: 'length'`, empty
|
|
127
|
+
OR partial) proves nothing and is retried once with a larger budget before the
|
|
128
|
+
probe settles on `false`.
|
|
129
|
+
- **Native tools:** a trivial tool with `tool_choice: 'required'` is sent; a
|
|
130
|
+
`tool_calls` result → supported, a `4xx` or a complete answer without tool calls →
|
|
131
|
+
unsupported, a truncation → the same one retry.
|
|
132
|
+
|
|
133
|
+
Both probes run concurrently. Override `OpenAiCompatibleProvider.detectCapabilities()`
|
|
134
|
+
for custom backends, or implement the optional `ILlmProvider.detectCapabilities()` in
|
|
135
|
+
your own provider.
|
|
136
|
+
|
|
137
|
+
> **`supportsJsonResponse` is a CONNECTION flag, but whether an answer must be JSON is
|
|
138
|
+
> a property of the PROMPT.** The JSON output contract is carried only by the
|
|
139
|
+
> `output_contract` / `tool_protocol_emulated` fragments (both `capability: 'emulated'`)
|
|
140
|
+
> and by `plan_protocol`. A **native**-tools run receives none of them and is asked for
|
|
141
|
+
> prose — attaching `response_format` on top is a contradiction the model can only
|
|
142
|
+
> resolve by inventing a shape of its own, which then reaches the user as the answer.
|
|
143
|
+
> The orchestrator therefore decides JSON mode per CALL, not per connection: pass
|
|
144
|
+
> `jsonResponse: false` in `LlmCompletionOptions` from any call whose prompt asks for
|
|
145
|
+
> prose. The option only ever NARROWS — it can never assert JSON mode for a connection
|
|
146
|
+
> whose endpoint was not probed for it.
|
|
126
147
|
|
|
127
148
|
### Backend examples (external, local, CLI)
|
|
128
149
|
|
|
@@ -96,7 +96,22 @@ export class CoreAiController {
|
|
|
96
96
|
res.setHeader('Cache-Control', 'no-cache');
|
|
97
97
|
res.setHeader('Connection', 'keep-alive');
|
|
98
98
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
99
|
+
// Disable proxy-side response buffering (nginx and friends honour this); without
|
|
100
|
+
// it an intermediary can hold the events until the response ends, which defeats
|
|
101
|
+
// the whole point of the stream.
|
|
102
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
99
103
|
res.flushHeaders?.();
|
|
104
|
+
|
|
105
|
+
// A prompt run can stay silent for a long time — a multi-step turn measured
|
|
106
|
+
// 30-40 s, and `ai.maxRunMs` allows up to two minutes. Many proxies close an
|
|
107
|
+
// idle connection at 60 s, which the client then sees as a turn that silently
|
|
108
|
+
// vanished. Comment frames are ignored by every SSE client and keep the
|
|
109
|
+
// connection observably alive.
|
|
110
|
+
const heartbeat = setInterval(() => {
|
|
111
|
+
res.write(': keep-alive\n\n');
|
|
112
|
+
}, 15_000);
|
|
113
|
+
heartbeat.unref?.();
|
|
114
|
+
|
|
100
115
|
try {
|
|
101
116
|
for await (const event of this.aiService.promptStream(input, serviceOptions)) {
|
|
102
117
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
@@ -104,6 +119,7 @@ export class CoreAiController {
|
|
|
104
119
|
} catch (err) {
|
|
105
120
|
res.write(`data: ${JSON.stringify({ message: (err as Error).message, type: 'error' })}\n\n`);
|
|
106
121
|
} finally {
|
|
122
|
+
clearInterval(heartbeat);
|
|
107
123
|
res.end();
|
|
108
124
|
}
|
|
109
125
|
}
|
|
@@ -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: {
|
|
103
|
+
mongoose: { type: String },
|
|
95
104
|
roles: RoleEnum.S_USER,
|
|
96
105
|
})
|
|
97
106
|
tenantId?: string = undefined;
|
|
@@ -133,9 +133,18 @@ export class CoreAiSlot extends CorePersistenceModel {
|
|
|
133
133
|
* slot is effectively system-wide.
|
|
134
134
|
*/
|
|
135
135
|
@UnifiedField({
|
|
136
|
+
// No `index: true`: declaring the `tenantId` PATH is what activates
|
|
137
|
+
// `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
|
|
138
|
+
// itself. Declaring it here as well makes Mongoose log
|
|
139
|
+
// "Duplicate schema index on {"tenantId":1}" on every boot.
|
|
140
|
+
//
|
|
141
|
+
// The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
|
|
142
|
+
// `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
|
|
143
|
+
// and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
|
|
144
|
+
// the model would lose its tenant filtering entirely.
|
|
136
145
|
description: 'Tenant id the slot applies to (auto-set; undefined = system-wide)',
|
|
137
146
|
isOptional: true,
|
|
138
|
-
mongoose: {
|
|
147
|
+
mongoose: { type: String },
|
|
139
148
|
roles: RoleEnum.ADMIN,
|
|
140
149
|
})
|
|
141
150
|
tenantId?: string = undefined;
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
227
|
-
* 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.
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']],
|