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 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.27",
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.27",
42
- "jeopi-utils": "16.2.27",
43
- "jeopi-wire": "16.2.27",
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
  },
@@ -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(),
@@ -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 = { readonly open: string; readonly close: string; readonly fenced?: boolean };
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
- return { open: match[0], close: `</${name}>`, index: match.index };
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 close = this.#buffer.indexOf(this.#closeTag);
151
- if (close === -1) {
152
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [this.#closeTag]);
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, close), events);
158
- this.#buffer = this.#buffer.slice(close + this.#closeTag.length);
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
  }