jeopi-ai 16.2.27 → 16.2.28
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/CHANGELOG.md +7 -0
- package/package.json +4 -4
- package/src/dialect/anthropic.ts +18 -0
- package/src/dialect/thinking.ts +88 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.28] - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `ThinkingInbandScanner` (leaked-reasoning healer for OpenAI-compatible/Ollama providers) only recognized a bare thinking tag once its closing `>` arrived — a model that drops the `>` entirely (`<thinke\n<reasoning text>`, going straight into its reasoning instead of finishing the tag) never resolved as an open at all: `bareTagPartialHold` holds the unterminated run only while the buffer stays within `MAX_BARE_TAG_NAME_LENGTH`, then gives up and flushes it as visible text once the reasoning that follows pushes the buffer past that bound. A well-formed close tag emitted later (`</thinking>`) then had no open to pair with either, so it also leaked verbatim — both the malformed open and the clean close ended up in the visible channel, sandwiching the reasoning they were meant to hide. Added `findUnterminatedBareThinkingOpen`: once a `<name` run is proven complete (immediately followed by anything other than `>`) and the name passes the existing one-typo thinking-name check, it's treated as an open with a `</thinking>` fallback close accepted alongside its own derived close — covering opens and closes that diverge independently within the same turn, matching the pattern actually observed in practice.
|
|
10
|
+
- `AnthropicInbandScanner`'s (native Claude/`xml` dialect) tag tokenizer (`parseTag`) parsed a `<...>` span by taking everything between `<` and the *first* `>` anywhere ahead in the buffer as that one tag's attributes, with no check that the span was a coherent single-line attribute list. An unterminated/malformed open (the same dropped-`>` shape above) therefore did not just leak as text: it silently swallowed every character up to and including the *next* legitimate `>` in the stream — including a real `</thinking>` close — as bogus "attributes", discarding the actual reasoning between them and misclassifying whatever text followed the close as hidden thinking instead of a visible reply. `parseTag` now rejects a match whose captured attrs span contains a newline or an embedded `<` (real single-line attribute lists here, e.g. `signature="…"`, never contain either), falling through to the existing malformed-tag handling that leaks the lone `<` as one visible character and rescans — trading a cosmetic leak for what was previously silent data loss and mislabeling.
|
|
11
|
+
|
|
5
12
|
## [16.2.27] - 2026-07-07
|
|
6
13
|
|
|
7
14
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "jeopi-ai",
|
|
4
|
-
"version": "16.2.
|
|
4
|
+
"version": "16.2.28",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/akillness/jeopi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"jeopi-catalog": "16.2.
|
|
42
|
-
"jeopi-utils": "16.2.
|
|
43
|
-
"jeopi-wire": "16.2.
|
|
41
|
+
"jeopi-catalog": "16.2.28",
|
|
42
|
+
"jeopi-utils": "16.2.28",
|
|
43
|
+
"jeopi-wire": "16.2.28",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/dialect/anthropic.ts
CHANGED
|
@@ -494,6 +494,23 @@ export class AnthropicInbandScanner implements InbandScanner {
|
|
|
494
494
|
|
|
495
495
|
const ALL_TAG_PREFIXES = [...BASE_TAG_PREFIXES, ...ANTHROPIC_THINKING_TAG_PREFIXES] as const;
|
|
496
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Parse one `<...>` span already isolated by `#peekTag` (buffer up to and
|
|
499
|
+
* including its first `>`).
|
|
500
|
+
*
|
|
501
|
+
* The `attrs` capture (`[^>]*` with `/s`, so it also matches newlines) has no
|
|
502
|
+
* bound on its own past "not another `>`" — an unterminated open tag whose
|
|
503
|
+
* name/spelling never resolves before the *next* legitimate `>` elsewhere in
|
|
504
|
+
* the buffer (e.g. a later `</thinking>`) would otherwise parse as one tag
|
|
505
|
+
* whose bogus "attributes" span silently swallows everything in between,
|
|
506
|
+
* including real content and that legitimate close tag. Reject the match
|
|
507
|
+
* when the attrs span contains a newline or an embedded `<`: real attribute
|
|
508
|
+
* lists here (e.g. `signature="…"`) are always single-line with no nested
|
|
509
|
+
* tags, so either is conclusive proof `raw` isn't one coherent tag. The
|
|
510
|
+
* caller (`#peekTag` returning `undefined`) then falls through to emitting
|
|
511
|
+
* the leading `<` as one visible character and rescanning — malformed input
|
|
512
|
+
* leaks as text instead of corrupting parser state.
|
|
513
|
+
*/
|
|
497
514
|
function parseTag(raw: string): ParsedTag | undefined {
|
|
498
515
|
const match = /^<\s*(\/?)\s*(?:(?<prefix>[A-Za-z_][\w.-]*):)?(?<localName>[A-Za-z_][\w.-]*)(?<attrs>[^>]*)>$/s.exec(
|
|
499
516
|
raw,
|
|
@@ -501,6 +518,7 @@ function parseTag(raw: string): ParsedTag | undefined {
|
|
|
501
518
|
const localName = match?.groups?.localName;
|
|
502
519
|
if (!match || !localName) return undefined;
|
|
503
520
|
const attrsText = match.groups?.attrs ?? "";
|
|
521
|
+
if (attrsText.includes("\n") || attrsText.includes("<")) return undefined;
|
|
504
522
|
return {
|
|
505
523
|
raw,
|
|
506
524
|
localName: localName.toLowerCase(),
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { partialSuffixOverlapAny } from "./coercion";
|
|
|
2
2
|
import { FencedThinkingScanner } from "./fenced-thinking";
|
|
3
3
|
import type { InbandScanEvent, InbandScanner } from "./types";
|
|
4
4
|
|
|
5
|
-
type Tag = {
|
|
5
|
+
type Tag = {
|
|
6
|
+
readonly open: string;
|
|
7
|
+
readonly close: string;
|
|
8
|
+
readonly fenced?: boolean;
|
|
9
|
+
readonly closeFallback?: string;
|
|
10
|
+
};
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Every dialect's in-band thinking section in its canonical `renderThinking`
|
|
@@ -74,12 +79,60 @@ function levenshteinAtMost(a: string, b: string, max: number): boolean {
|
|
|
74
79
|
* {@link isThinkingTagName}, paired with its matching `</name>` close. Runs
|
|
75
80
|
* alongside the literal {@link TAGS} idioms so `<think>`/`<thinking>`/
|
|
76
81
|
* `<scratchpad>` and their one-typo variants share a single fuzzy path.
|
|
82
|
+
* `closeFallback` additionally accepts a clean `</thinking>` close even when
|
|
83
|
+
* the open itself was a typo'd variant — evidence shows a model's open and
|
|
84
|
+
* close spellings can diverge independently within the same turn.
|
|
77
85
|
*/
|
|
78
86
|
function findBareThinkingOpen(buffer: string): (Tag & { index: number }) | undefined {
|
|
79
87
|
for (const match of buffer.matchAll(BARE_TAG_OPEN_PATTERN)) {
|
|
80
88
|
const name = match[1]!;
|
|
81
89
|
if (!isThinkingTagName(name)) continue;
|
|
82
|
-
|
|
90
|
+
const close = `</${name}>`;
|
|
91
|
+
return {
|
|
92
|
+
open: match[0],
|
|
93
|
+
close,
|
|
94
|
+
closeFallback: close === "</thinking>" ? undefined : "</thinking>",
|
|
95
|
+
index: match.index,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect a bare thinking-tag open that can never close validly: `<` followed
|
|
103
|
+
* by a name-shaped run (passing {@link isThinkingTagName}) that is itself
|
|
104
|
+
* immediately followed by something other than `>` — whitespace, a newline,
|
|
105
|
+
* or any other character. A model that drops the closing `>` (or garbles
|
|
106
|
+
* `<thinking>` into `<thinke` before moving straight into its actual
|
|
107
|
+
* reasoning) leaves exactly this shape: `<thinke\n<reasoning text>`. Unlike
|
|
108
|
+
* {@link bareTagPartialHold}, which holds a run that could *still* grow into
|
|
109
|
+
* a valid `<name>`, this fires only once the buffer proves the run is over
|
|
110
|
+
* and no `>` arrived — so it never races with, or preempts, the
|
|
111
|
+
* well-formed/one-typo match above (that one requires `>` immediately after
|
|
112
|
+
* the name; this one requires the opposite).
|
|
113
|
+
*
|
|
114
|
+
* `closeFallback` is always `</thinking>`: the malformed open carries no
|
|
115
|
+
* reliable spelling to expect back, so any well-formed thinking close ends
|
|
116
|
+
* the block. This is the exact failure mode reported in practice: a broken
|
|
117
|
+
* open (`<thinke`, no `>`) paired with a clean `</thinking>` close — without
|
|
118
|
+
* this, neither tag is ever recognized and both leak verbatim into the
|
|
119
|
+
* visible channel around the reasoning text they were meant to wrap.
|
|
120
|
+
*/
|
|
121
|
+
function findUnterminatedBareThinkingOpen(buffer: string): (Tag & { index: number }) | undefined {
|
|
122
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
123
|
+
if (buffer[i] !== "<") continue;
|
|
124
|
+
const nameStart = i + 1;
|
|
125
|
+
if (!/[A-Za-z]/.test(buffer[nameStart] ?? "")) continue;
|
|
126
|
+
let end = nameStart + 1;
|
|
127
|
+
while (end < buffer.length && end - nameStart < MAX_BARE_TAG_NAME_LENGTH && /[A-Za-z0-9]/.test(buffer[end]!)) {
|
|
128
|
+
end++;
|
|
129
|
+
}
|
|
130
|
+
// Still growable (name run hit the end of the buffer with no terminator
|
|
131
|
+
// yet), or a valid `>`-closed tag — not this function's job either way.
|
|
132
|
+
if (end >= buffer.length || buffer[end] === ">") continue;
|
|
133
|
+
const name = buffer.slice(nameStart, end);
|
|
134
|
+
if (!isThinkingTagName(name)) continue;
|
|
135
|
+
return { open: buffer.slice(i, end), close: `</${name}>`, closeFallback: "</thinking>", index: i };
|
|
83
136
|
}
|
|
84
137
|
return undefined;
|
|
85
138
|
}
|
|
@@ -91,7 +144,12 @@ function findBareThinkingOpen(buffer: string): (Tag & { index: number }) | undef
|
|
|
91
144
|
* matched {@link isThinkingTagName} — e.g. `<thinke` must survive until the
|
|
92
145
|
* closing `>` arrives and resolves it. Bounded by
|
|
93
146
|
* {@link MAX_BARE_TAG_NAME_LENGTH} so an angle bracket in ordinary prose
|
|
94
|
-
* can't stall the stream indefinitely
|
|
147
|
+
* can't stall the stream indefinitely; once that bound is exceeded without a
|
|
148
|
+
* `>`, {@link findUnterminatedBareThinkingOpen} has already had — and taken —
|
|
149
|
+
* its chance to recognize a conclusively-broken thinking-tag attempt on the
|
|
150
|
+
* same buffer, so anything still unresolved here is either not
|
|
151
|
+
* thinking-tag-shaped or a `<` in ordinary prose, and is safe to release as
|
|
152
|
+
* visible text.
|
|
95
153
|
*/
|
|
96
154
|
function bareTagPartialHold(buffer: string): number {
|
|
97
155
|
const openIndex = buffer.lastIndexOf("<");
|
|
@@ -104,6 +162,11 @@ function bareTagPartialHold(buffer: string): number {
|
|
|
104
162
|
export class ThinkingInbandScanner implements InbandScanner {
|
|
105
163
|
#buffer = "";
|
|
106
164
|
#closeTag = "";
|
|
165
|
+
/** Alternate close accepted alongside {@link #closeTag} — set when the open
|
|
166
|
+
* tag's own spelling can't be trusted as a predictor of the close spelling
|
|
167
|
+
* (see {@link findUnterminatedBareThinkingOpen}). Empty when there is no
|
|
168
|
+
* fallback, in which case only `#closeTag` is checked. */
|
|
169
|
+
#closeFallback = "";
|
|
107
170
|
#thinking = "";
|
|
108
171
|
/** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
|
|
109
172
|
#fenced: FencedThinkingScanner | undefined;
|
|
@@ -125,6 +188,7 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
125
188
|
}
|
|
126
189
|
this.#buffer = "";
|
|
127
190
|
this.#closeTag = "";
|
|
191
|
+
this.#closeFallback = "";
|
|
128
192
|
return events;
|
|
129
193
|
}
|
|
130
194
|
|
|
@@ -140,6 +204,7 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
140
204
|
events.push({ type: "thinkingEnd", thinking: this.#thinking });
|
|
141
205
|
this.#thinking = "";
|
|
142
206
|
this.#closeTag = "";
|
|
207
|
+
this.#closeFallback = "";
|
|
143
208
|
this.#fenced = undefined;
|
|
144
209
|
}
|
|
145
210
|
if (this.#fenced) break;
|
|
@@ -147,18 +212,20 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
147
212
|
}
|
|
148
213
|
if (this.#buffer.length === 0) break;
|
|
149
214
|
if (this.#closeTag) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
215
|
+
const candidates = this.#closeFallback ? [this.#closeTag, this.#closeFallback] : [this.#closeTag];
|
|
216
|
+
const found = earliestIndexOf(this.#buffer, candidates);
|
|
217
|
+
if (!found) {
|
|
218
|
+
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, candidates);
|
|
153
219
|
this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
|
|
154
220
|
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
|
155
221
|
break;
|
|
156
222
|
}
|
|
157
|
-
this.#emitThinking(this.#buffer.slice(0,
|
|
158
|
-
this.#buffer = this.#buffer.slice(
|
|
223
|
+
this.#emitThinking(this.#buffer.slice(0, found.index), events);
|
|
224
|
+
this.#buffer = this.#buffer.slice(found.index + found.tag.length);
|
|
159
225
|
events.push({ type: "thinkingEnd", thinking: this.#thinking });
|
|
160
226
|
this.#thinking = "";
|
|
161
227
|
this.#closeTag = "";
|
|
228
|
+
this.#closeFallback = "";
|
|
162
229
|
continue;
|
|
163
230
|
}
|
|
164
231
|
|
|
@@ -175,6 +242,7 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
175
242
|
if (tag.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, tag.index) });
|
|
176
243
|
this.#buffer = this.#buffer.slice(tag.index + tag.open.length);
|
|
177
244
|
this.#closeTag = tag.close;
|
|
245
|
+
this.#closeFallback = tag.closeFallback ?? "";
|
|
178
246
|
this.#thinking = "";
|
|
179
247
|
if (tag.fenced) this.#fenced = new FencedThinkingScanner();
|
|
180
248
|
events.push({ type: "thinkingStart" });
|
|
@@ -189,6 +257,16 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
189
257
|
}
|
|
190
258
|
}
|
|
191
259
|
|
|
260
|
+
/** Earliest match among several candidate substrings, or `undefined` if none occur. */
|
|
261
|
+
function earliestIndexOf(buffer: string, candidates: readonly string[]): { index: number; tag: string } | undefined {
|
|
262
|
+
let best: { index: number; tag: string } | undefined;
|
|
263
|
+
for (const tag of candidates) {
|
|
264
|
+
const index = buffer.indexOf(tag);
|
|
265
|
+
if (index !== -1 && (!best || index < best.index)) best = { index, tag };
|
|
266
|
+
}
|
|
267
|
+
return best;
|
|
268
|
+
}
|
|
269
|
+
|
|
192
270
|
function findEarliestOpen(buffer: string): (Tag & { index: number }) | undefined {
|
|
193
271
|
let best: (Tag & { index: number }) | undefined;
|
|
194
272
|
for (const tag of TAGS) {
|
|
@@ -197,5 +275,7 @@ function findEarliestOpen(buffer: string): (Tag & { index: number }) | undefined
|
|
|
197
275
|
}
|
|
198
276
|
const bare = findBareThinkingOpen(buffer);
|
|
199
277
|
if (bare && (!best || bare.index < best.index)) best = bare;
|
|
278
|
+
const unterminated = findUnterminatedBareThinkingOpen(buffer);
|
|
279
|
+
if (unterminated && (!best || unterminated.index < best.index)) best = unterminated;
|
|
200
280
|
return best;
|
|
201
281
|
}
|