@gajae-code/ai 0.17.2 → 0.17.4

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/types/auth-gateway/server.d.ts +23 -1
  3. package/dist/types/auth-storage.d.ts +12 -1
  4. package/dist/types/providers/anthropic.d.ts +1 -1
  5. package/dist/types/providers/cursor.d.ts +10 -0
  6. package/dist/types/providers/openai-completions.d.ts +9 -1
  7. package/dist/types/types.d.ts +16 -0
  8. package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
  9. package/dist/types/utils/fallback-transport.d.ts +4 -0
  10. package/dist/types/utils/h2-fetch.d.ts +8 -7
  11. package/dist/types/utils/stream-repetition-guard.d.ts +107 -0
  12. package/dist/types/utils/tool-call-healing.d.ts +4 -0
  13. package/dist/types/utils/tool-fence-strip.d.ts +27 -0
  14. package/package.json +3 -3
  15. package/src/auth-gateway/server.ts +48 -9
  16. package/src/auth-storage.ts +185 -48
  17. package/src/model-pricing.ts +22 -0
  18. package/src/model-thinking.ts +40 -4
  19. package/src/models.json +191 -15
  20. package/src/providers/anthropic.d.ts +1 -1
  21. package/src/providers/anthropic.ts +1 -1
  22. package/src/providers/cursor.d.ts +10 -0
  23. package/src/providers/cursor.ts +144 -24
  24. package/src/providers/openai-completions.d.ts +9 -1
  25. package/src/providers/openai-completions.ts +371 -126
  26. package/src/stream.ts +7 -0
  27. package/src/types.d.ts +16 -0
  28. package/src/types.ts +17 -0
  29. package/src/utils/discovery/openai-compatible.ts +16 -2
  30. package/src/utils/fallback-transport.d.ts +4 -0
  31. package/src/utils/fallback-transport.ts +11 -0
  32. package/src/utils/h2-fetch.ts +65 -26
  33. package/src/utils/http-inspector.ts +2 -0
  34. package/src/utils/idle-iterator.ts +109 -96
  35. package/src/utils/json-parse.ts +12 -4
  36. package/src/utils/stream-repetition-guard.d.ts +107 -0
  37. package/src/utils/stream-repetition-guard.ts +290 -0
  38. package/src/utils/tool-call-healing.d.ts +4 -0
  39. package/src/utils/tool-call-healing.ts +4 -0
  40. package/src/utils/tool-fence-strip.d.ts +27 -0
  41. package/src/utils/tool-fence-strip.ts +64 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Runaway-repetition detector for a model's streamed text or thinking channel.
3
+ *
4
+ * Some models fall into a decode loop and emit the same sentence — or the same
5
+ * short token run — until the turn's budget runs out. Nothing errors: the tool
6
+ * calls in the same message still execute, and the transcript just fills with
7
+ * dozens of identical lines (#5624).
8
+ *
9
+ * This is a pure state machine with no provider knowledge: `feed()` takes a
10
+ * chunk of streamed text and returns the prefix that is safe to emit. Until it
11
+ * trips, that prefix is the chunk itself, so a healthy stream passes through
12
+ * byte for byte. Once it trips, it emits nothing further and {@link takeTrip}
13
+ * hands the caller a one-shot signal to abort the request.
14
+ *
15
+ * Use one instance per stream **per channel**. Interleaving the visible-text
16
+ * and reasoning channels through a single instance would splice unrelated
17
+ * tokens into the same window and manufacture patterns that were never
18
+ * streamed.
19
+ */
20
+
21
+ /** Consecutive repeats of one unit that trip the guard. */
22
+ export const DEFAULT_REPETITION_THRESHOLD = 12;
23
+
24
+ /**
25
+ * Largest accepted repetition threshold.
26
+ *
27
+ * Token retention is `MAX_NGRAM_TOKENS * (threshold + 1)`, so capping the
28
+ * threshold is what makes the guard's memory bounded: at this cap it retains
29
+ * at most 64 * 129 = 8256 tokens, against 832 at the default. That is roughly
30
+ * ten times the default's headroom — generous for a caller who genuinely wants
31
+ * a laxer guard — while keeping a hostile or buggy option from turning the
32
+ * detector into an unbounded buffer (#5627 review r6).
33
+ */
34
+ export const MAX_REPETITION_THRESHOLD = 128;
35
+
36
+ /**
37
+ * `errorCode` stamped on a turn this guard stopped. A bounded classifier, never
38
+ * raw model text — consumers branch on it to tell a local decode-loop stop from
39
+ * a client cancellation or a transport fault (#5627).
40
+ */
41
+ export const REPETITION_GUARD_ERROR_CODE = "repetition_guard_tripped";
42
+
43
+ /**
44
+ * Wire-safe `errorMessage` for a turn this guard stopped. A literal with zero
45
+ * interpolation — not the sample, not the channel, not the repeat count.
46
+ *
47
+ * The auth gateway forwards `errorMessage` to API clients on the streaming path
48
+ * (`redactGatewayMessage` only strips credential-shaped text), so anything
49
+ * interpolated here is raw model output published verbatim. It also reaches
50
+ * `classifyGatewayError`, which keyword-matches on message text, so a repeated
51
+ * `quota` or `forbidden` in a sample could pick the HTTP status (#5627 r5).
52
+ *
53
+ * The repeated unit is not logged either: the provider logs bounded metadata
54
+ * only, because the default log transport persists metadata verbatim to a
55
+ * rotating file on disk (#5627 review r6). {@link StreamRepetitionTrip.sample}
56
+ * stays in memory for callers that want it.
57
+ */
58
+ export const REPETITION_GUARD_STOP_MESSAGE = "Stopped the turn: the model produced runaway repeated output.";
59
+
60
+ /**
61
+ * Shortest n-gram window compared when the repeats carry no newline to split
62
+ * on. Requiring eight tokens keeps ordinary repetition — a run of zeroes in a
63
+ * matrix, a ruler of dashes — from reading as a decode loop.
64
+ */
65
+ const MIN_NGRAM_TOKENS = 8;
66
+
67
+ /**
68
+ * Longest n-gram window compared. A unit of `u` tokens is caught at the first
69
+ * multiple of `u` at or above {@link MIN_NGRAM_TOKENS}, so this covers every
70
+ * repeating unit up to 57 tokens long.
71
+ */
72
+ const MAX_NGRAM_TOKENS = 64;
73
+
74
+ /** Longest sample retained for the human-readable diagnostic. */
75
+ const MAX_SAMPLE_CHARS = 120;
76
+
77
+ export type RepetitionUnitKind = "line" | "ngram";
78
+
79
+ export interface StreamRepetitionTrip {
80
+ /** Whether the repeats were whole lines or an n-gram inside one line. */
81
+ readonly kind: RepetitionUnitKind;
82
+ /** Consecutive repeats observed when the guard tripped. */
83
+ readonly repeats: number;
84
+ /** Normalized, truncated sample of the repeated unit, for diagnostics. */
85
+ readonly sample: string;
86
+ }
87
+
88
+ export interface StreamRepetitionGuardOptions {
89
+ /**
90
+ * Consecutive repeats that trip the guard. Defaults to 12. Normalized by
91
+ * {@link normalizeThreshold} — non-finite values fall back to the default,
92
+ * fractional values are floored, and the result is clamped into
93
+ * `[2, MAX_REPETITION_THRESHOLD]`.
94
+ */
95
+ readonly threshold?: number;
96
+ }
97
+
98
+ function isWhitespace(ch: string): boolean {
99
+ return ch === " " || ch === "\t" || ch === "\r" || ch === "\n" || ch === "\f" || ch === "\v";
100
+ }
101
+
102
+ /** Trim and collapse internal whitespace so re-wrapped repeats still compare equal. */
103
+ function normalize(unit: string): string {
104
+ return unit.trim().replace(/\s+/g, " ");
105
+ }
106
+
107
+ function sampleOf(unit: string): string {
108
+ return unit.length <= MAX_SAMPLE_CHARS ? unit : `${unit.slice(0, MAX_SAMPLE_CHARS - 1)}…`;
109
+ }
110
+
111
+ /**
112
+ * Coerce a caller-supplied threshold into an integer the guard can actually
113
+ * reach, and that bounds its state.
114
+ *
115
+ * `repetitionGuard` is public on `SimpleStreamOptions`, so this value arrives
116
+ * from outside the package and is only typed `number` (#5627 review r6). The
117
+ * previous `Math.max(2, value)` admitted three broken inputs:
118
+ *
119
+ * - `NaN` — `Math.max(2, NaN)` is `NaN`, and every comparison against `NaN`
120
+ * is false, so the guard silently never tripped: detection off, no error.
121
+ * - `Infinity` / a huge finite value — the threshold is unreachable *and*
122
+ * `#maxTrackedTokens` becomes effectively unbounded, so `#tokens` grows
123
+ * for the whole stream while detection can never fire.
124
+ * - a fraction like `2.5` — an integer repeat counter never equals it, so
125
+ * the effective threshold silently becomes the next integer up.
126
+ *
127
+ * Normalizes rather than throws. This runs on the streaming hot path, and
128
+ * turning a bad caller option into a failed request is worse than running the
129
+ * guard at its documented default.
130
+ */
131
+ function normalizeThreshold(value: number | undefined): number {
132
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_REPETITION_THRESHOLD;
133
+ // Floor before clamping so the counter can hit the threshold exactly.
134
+ return Math.min(MAX_REPETITION_THRESHOLD, Math.max(2, Math.floor(value)));
135
+ }
136
+
137
+ export class StreamRepetitionGuard {
138
+ readonly #threshold: number;
139
+ /** Tokens are only ever inspected from the tail, so older ones can be dropped. */
140
+ readonly #maxTrackedTokens: number;
141
+
142
+ #line = "";
143
+ #lastLine = "";
144
+ #lineRepeats = 0;
145
+ #tokens: string[] = [];
146
+ #token = "";
147
+ #trip: StreamRepetitionTrip | undefined;
148
+ #tripTaken = false;
149
+ #finalized = false;
150
+
151
+ constructor(options?: StreamRepetitionGuardOptions) {
152
+ this.#threshold = normalizeThreshold(options?.threshold);
153
+ // Derived from the *normalized* threshold, never the raw option, so the
154
+ // capacity is finite by construction and provably at most
155
+ // `MAX_NGRAM_TOKENS * (MAX_REPETITION_THRESHOLD + 1)` for every input.
156
+ this.#maxTrackedTokens = MAX_NGRAM_TOKENS * (this.#threshold + 1);
157
+ }
158
+
159
+ /**
160
+ * The threshold actually in force — the caller's option after
161
+ * {@link normalizeThreshold}, which may differ from what was passed.
162
+ */
163
+ get threshold(): number {
164
+ return this.#threshold;
165
+ }
166
+
167
+ get tripped(): boolean {
168
+ return this.#trip !== undefined;
169
+ }
170
+
171
+ get trip(): StreamRepetitionTrip | undefined {
172
+ return this.#trip;
173
+ }
174
+
175
+ /**
176
+ * Returns the trip exactly once, then `undefined` forever. Callers drive a
177
+ * one-shot side effect (aborting the request) off this, so the once-only
178
+ * latch lives here rather than being re-implemented at each call site.
179
+ */
180
+ takeTrip(): StreamRepetitionTrip | undefined {
181
+ if (!this.#trip || this.#tripTaken) return undefined;
182
+ this.#tripTaken = true;
183
+ return this.#trip;
184
+ }
185
+
186
+ /**
187
+ * Feed a chunk of streamed text. Returns the portion safe to emit: the whole
188
+ * chunk while healthy, the prefix up to the repeat that tripped the guard on
189
+ * the chunk that trips it, and nothing at all after that.
190
+ */
191
+ feed(text: string): string {
192
+ if (this.#trip || text.length === 0) return "";
193
+ for (let i = 0; i < text.length; i++) {
194
+ const ch = text[i];
195
+ if (isWhitespace(ch)) {
196
+ this.#closeToken();
197
+ if (ch === "\n") {
198
+ this.#closeLine();
199
+ } else {
200
+ this.#line += ch;
201
+ }
202
+ } else {
203
+ this.#line += ch;
204
+ this.#token += ch;
205
+ }
206
+ // Cut after the character that completed the offending repeat, so the
207
+ // caller still renders a bounded `threshold` copies and no more.
208
+ if (this.#trip) return text.slice(0, i + 1);
209
+ }
210
+ return text;
211
+ }
212
+
213
+ /**
214
+ * Close the in-progress unit at end of stream and run detection once more.
215
+ *
216
+ * `feed()` only closes a token on whitespace and a line on `\n`, so a stream
217
+ * whose final repeat arrives without a trailing newline left the last copy
218
+ * uncounted and the turn read as a healthy completion (#5627 review r5).
219
+ *
220
+ * Emits nothing — everything `feed()` returned has already been rendered by
221
+ * the time this runs. A trip found here therefore classifies the turn while
222
+ * the last copy is already on screen; that is intended. Idempotent.
223
+ */
224
+ finalize(): void {
225
+ if (this.#finalized || this.#trip) return;
226
+ this.#finalized = true;
227
+ // Token first: the trailing partial must enter `#tokens` so the n-gram
228
+ // scan sees it before the line comparison closes the buffer.
229
+ this.#closeToken();
230
+ this.#closeLine();
231
+ }
232
+
233
+ #closeLine(): void {
234
+ const line = normalize(this.#line);
235
+ this.#line = "";
236
+ // Blank lines separate repeats in some transcripts; they must not reset
237
+ // the run, and an all-blank stretch must not read as a repeat of itself.
238
+ if (line.length === 0) return;
239
+ if (line === this.#lastLine) {
240
+ this.#lineRepeats += 1;
241
+ if (this.#lineRepeats >= this.#threshold) {
242
+ this.#trip = { kind: "line", repeats: this.#lineRepeats, sample: sampleOf(line) };
243
+ }
244
+ return;
245
+ }
246
+ this.#lastLine = line;
247
+ this.#lineRepeats = 1;
248
+ }
249
+
250
+ #closeToken(): void {
251
+ if (this.#token.length === 0) return;
252
+ this.#tokens.push(this.#token);
253
+ this.#token = "";
254
+ if (this.#tokens.length > this.#maxTrackedTokens) {
255
+ this.#tokens = this.#tokens.slice(-MAX_NGRAM_TOKENS * this.#threshold);
256
+ }
257
+ this.#detectNgramLoop();
258
+ }
259
+
260
+ /**
261
+ * Looks for a tail made of `threshold` back-to-back copies of the same
262
+ * window. Scanning every window size from {@link MIN_NGRAM_TOKENS} up means
263
+ * any repeating unit is caught at some multiple of its own length, so the
264
+ * unit length itself never has to be guessed.
265
+ */
266
+ #detectNgramLoop(): void {
267
+ const total = this.#tokens.length;
268
+ const maxWindow = Math.min(MAX_NGRAM_TOKENS, Math.floor(total / this.#threshold));
269
+ for (let window = MIN_NGRAM_TOKENS; window <= maxWindow; window++) {
270
+ let matched = true;
271
+ for (let copy = 1; copy < this.#threshold && matched; copy++) {
272
+ const start = total - window * (copy + 1);
273
+ for (let k = 0; k < window; k++) {
274
+ if (this.#tokens[start + k] !== this.#tokens[start + window + k]) {
275
+ matched = false;
276
+ break;
277
+ }
278
+ }
279
+ }
280
+ if (matched) {
281
+ this.#trip = {
282
+ kind: "ngram",
283
+ repeats: this.#threshold,
284
+ sample: sampleOf(this.#tokens.slice(total - window).join(" ")),
285
+ };
286
+ return;
287
+ }
288
+ }
289
+ }
290
+ }
@@ -17,6 +17,10 @@
17
17
  * the end of a chunk is held back until the next chunk arrives.
18
18
  */
19
19
  import { type UnicodeEscapeEvidence } from "./json-parse";
20
+ declare const TOKENS: readonly ["<|tool_calls_section_begin|>", "<|tool_calls_section_end|>", "<|tool_call_begin|>", "<|tool_call_end|>", "<|tool_call_argument_begin|>"];
21
+ /** Maximum buffered partial-token length before we give up holding back. */
22
+ declare const MAX_PARTIAL_HOLD = 64;
23
+ export { MAX_PARTIAL_HOLD as MAX_TOOL_FENCE_PARTIAL_HOLD, TOKENS as TOOL_FENCE_TOKENS };
20
24
  export interface HealedToolCall {
21
25
  readonly id: string;
22
26
  readonly name: string;
@@ -35,6 +35,10 @@ const TOKENS = [TOK_SECTION_BEGIN, TOK_SECTION_END, TOK_CALL_BEGIN, TOK_CALL_END
35
35
  /** Maximum buffered partial-token length before we give up holding back. */
36
36
  const MAX_PARTIAL_HOLD = 64;
37
37
 
38
+ // Re-exported so other streaming filters (e.g. the thinking-channel fence
39
+ // stripper) share this token list instead of duplicating the literals.
40
+ export { MAX_PARTIAL_HOLD as MAX_TOOL_FENCE_PARTIAL_HOLD, TOKENS as TOOL_FENCE_TOKENS };
41
+
38
42
  export interface HealedToolCall {
39
43
  readonly id: string;
40
44
  readonly name: string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Streaming-safe removal of chat-template tool-call fence tokens.
3
+ *
4
+ * Unlike {@link ToolCallHealer}, this reconstructs nothing — it only deletes
5
+ * the markers. That makes it safe for the **reasoning channel**, where a leaked
6
+ * `<|tool_call_end|>` is pure noise: the structured `tool_calls` payload is the
7
+ * single source of truth, and the healer's doc comment warns that feeding the
8
+ * reasoning channel into its accumulator corrupts the holdback buffer (#5624).
9
+ *
10
+ * Deliberately NOT applied to the visible text channel: a fence token the
11
+ * assistant *talks about* in prose, outside an active section, must survive as
12
+ * text (see packages/ai/CHANGELOG.md:1094).
13
+ */
14
+ /** Remove every complete fence token from `text`. Pure; no stream state. */
15
+ export declare function stripToolFenceTokens(text: string): string;
16
+ /**
17
+ * Stateful wrapper around {@link stripToolFenceTokens} that holds back a
18
+ * partial token at the end of a chunk until the next chunk arrives, so a fence
19
+ * split across a streaming boundary is still removed. One instance per stream.
20
+ */
21
+ export declare class ToolFenceStripper {
22
+ #private;
23
+ /** Feed a chunk; returns the stripped text safe to emit now. */
24
+ feed(text: string): string;
25
+ /** Drain any held-back partial at end of stream. It never completed, so emit it. */
26
+ flush(): string;
27
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Streaming-safe removal of chat-template tool-call fence tokens.
3
+ *
4
+ * Unlike {@link ToolCallHealer}, this reconstructs nothing — it only deletes
5
+ * the markers. That makes it safe for the **reasoning channel**, where a leaked
6
+ * `<|tool_call_end|>` is pure noise: the structured `tool_calls` payload is the
7
+ * single source of truth, and the healer's doc comment warns that feeding the
8
+ * reasoning channel into its accumulator corrupts the holdback buffer (#5624).
9
+ *
10
+ * Deliberately NOT applied to the visible text channel: a fence token the
11
+ * assistant *talks about* in prose, outside an active section, must survive as
12
+ * text (see packages/ai/CHANGELOG.md:1094).
13
+ */
14
+
15
+ import { MAX_TOOL_FENCE_PARTIAL_HOLD, TOOL_FENCE_TOKENS } from "./tool-call-healing";
16
+
17
+ /** Remove every complete fence token from `text`. Pure; no stream state. */
18
+ export function stripToolFenceTokens(text: string): string {
19
+ let out = text;
20
+ for (const token of TOOL_FENCE_TOKENS) {
21
+ if (out.includes(token)) out = out.split(token).join("");
22
+ }
23
+ return out;
24
+ }
25
+
26
+ /**
27
+ * Length of the trailing run that could still grow into a fence token. Every
28
+ * token starts with `<` and contains no further `<`, so a genuine partial can
29
+ * only begin at the last `<` in the buffer.
30
+ */
31
+ function trailingPartialTokenLength(text: string): number {
32
+ const start = text.lastIndexOf("<");
33
+ if (start < 0 || text.length - start > MAX_TOOL_FENCE_PARTIAL_HOLD) return 0;
34
+ const suffix = text.slice(start);
35
+ for (const token of TOOL_FENCE_TOKENS) {
36
+ if (token.length > suffix.length && token.startsWith(suffix)) return suffix.length;
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ /**
42
+ * Stateful wrapper around {@link stripToolFenceTokens} that holds back a
43
+ * partial token at the end of a chunk until the next chunk arrives, so a fence
44
+ * split across a streaming boundary is still removed. One instance per stream.
45
+ */
46
+ export class ToolFenceStripper {
47
+ #hold = "";
48
+
49
+ /** Feed a chunk; returns the stripped text safe to emit now. */
50
+ feed(text: string): string {
51
+ if (text.length === 0) return "";
52
+ const buffer = this.#hold + text;
53
+ const held = trailingPartialTokenLength(buffer);
54
+ this.#hold = held > 0 ? buffer.slice(buffer.length - held) : "";
55
+ return stripToolFenceTokens(buffer.slice(0, buffer.length - held));
56
+ }
57
+
58
+ /** Drain any held-back partial at end of stream. It never completed, so emit it. */
59
+ flush(): string {
60
+ const rest = this.#hold;
61
+ this.#hold = "";
62
+ return stripToolFenceTokens(rest);
63
+ }
64
+ }