@oh-my-pi/pi-ai 17.3.4 → 17.3.7

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 (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/auth-storage.d.ts +12 -2
  3. package/dist/types/error/rate-limit.d.ts +5 -2
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/oneshot-retry.d.ts +89 -0
  6. package/dist/types/providers/anthropic-wire.d.ts +18 -5
  7. package/dist/types/providers/openai-shared.d.ts +1 -1
  8. package/dist/types/types.d.ts +5 -4
  9. package/dist/types/usage/kimi.d.ts +3 -1
  10. package/dist/types/utils/schema/strict-tool-validation.d.ts +10 -1
  11. package/dist/types/utils/schema/wire.d.ts +6 -0
  12. package/dist/types/utils/thinking-loop.d.ts +12 -9
  13. package/package.json +5 -5
  14. package/src/auth-storage.ts +36 -5
  15. package/src/error/flags.ts +6 -2
  16. package/src/error/rate-limit.ts +31 -2
  17. package/src/index.ts +1 -0
  18. package/src/oneshot-retry.ts +230 -0
  19. package/src/providers/anthropic-messages-server.ts +5 -5
  20. package/src/providers/anthropic-wire.ts +31 -5
  21. package/src/providers/anthropic.ts +15 -4
  22. package/src/providers/cursor.ts +44 -4
  23. package/src/providers/openai-completions.ts +107 -63
  24. package/src/providers/openai-responses.ts +13 -10
  25. package/src/providers/openai-shared.ts +10 -3
  26. package/src/registry/alibaba-coding-plan.ts +2 -2
  27. package/src/registry/oauth/kimi.ts +20 -0
  28. package/src/stream.ts +59 -57
  29. package/src/types.ts +5 -4
  30. package/src/usage/kimi.ts +15 -0
  31. package/src/usage/umans.ts +93 -20
  32. package/src/utils/leaked-thinking-stream.ts +2 -2
  33. package/src/utils/schema/strict-tool-validation.ts +39 -5
  34. package/src/utils/schema/wire.ts +31 -0
  35. package/src/utils/thinking-loop.ts +96 -63
package/src/usage/kimi.ts CHANGED
@@ -2,6 +2,7 @@ import { toNumber } from "@oh-my-pi/pi-catalog/utils";
2
2
  import { $env } from "@oh-my-pi/pi-utils";
3
3
  import { getKimiCommonHeaders } from "../registry/oauth/kimi";
4
4
  import type {
5
+ CredentialRankingStrategy,
5
6
  UsageAmount,
6
7
  UsageFetchContext,
7
8
  UsageFetchParams,
@@ -285,6 +286,7 @@ export const kimiUsageProvider: UsageProvider = {
285
286
  fetchedAt: nowMs,
286
287
  limits,
287
288
  metadata: {
289
+ accountId: credential.accountId,
288
290
  endpoint: url,
289
291
  },
290
292
  raw: parsed.raw,
@@ -293,3 +295,16 @@ export const kimiUsageProvider: UsageProvider = {
293
295
  return report;
294
296
  },
295
297
  };
298
+
299
+ /** Ranks Kimi OAuth accounts by the canonical 5-hour and 7-day quota windows. */
300
+ export const kimiRankingStrategy: CredentialRankingStrategy = {
301
+ findWindowLimits: report => ({
302
+ primary: report.limits.find(limit => limit.window?.id === "5h"),
303
+ secondary: report.limits.find(limit => limit.window?.id === "7d"),
304
+ }),
305
+ scopeLimits: report => report.limits.filter(limit => limit.window?.id === "5h" || limit.window?.id === "7d"),
306
+ windowDefaults: {
307
+ primaryMs: 5 * HOUR_MS,
308
+ secondaryMs: 7 * DAY_MS,
309
+ },
310
+ };
@@ -23,9 +23,14 @@ interface UmansUsagePayload {
23
23
  requests?: { limit?: number; hard_cap?: number | null; window_seconds?: number };
24
24
  concurrency?: { limit?: number; hard_cap?: number | null };
25
25
  };
26
+ /** Rolling 5h window metadata; `resets_at` anchors the status-line countdown. */
27
+ window?: { started_at?: string; resets_at?: string; remaining_minutes?: number };
26
28
  usage?: {
27
29
  requests_in_window?: number;
28
30
  remaining_requests?: number;
31
+ /** Model-weighted "effective requests" (umans-flash counts 0.5). */
32
+ weighted_in_window?: number;
33
+ weighted_remaining_requests?: number;
29
34
  concurrent_sessions?: number;
30
35
  tokens_in?: number;
31
36
  tokens_out?: number;
@@ -55,6 +60,18 @@ function resolveStatus(usedFraction: number | undefined): UsageStatus | undefine
55
60
  return "ok";
56
61
  }
57
62
 
63
+ /**
64
+ * Soft-cap status never reaches `exhausted`: hitting the effective-request
65
+ * limit only means burst headroom is being consumed — Umans throttles (429)
66
+ * only near the burst ceiling, which the hard row tracks. `exhausted` must
67
+ * stay off this row or the usage-aware fallback demotes a healthy account.
68
+ */
69
+ function softCapStatus(usedFraction: number | undefined): UsageStatus | undefined {
70
+ if (usedFraction === undefined) return undefined;
71
+ if (usedFraction >= 0.9) return "warning";
72
+ return "ok";
73
+ }
74
+
58
75
  function buildAmount(args: {
59
76
  used: number | undefined;
60
77
  limit: number | undefined;
@@ -75,30 +92,88 @@ function buildAmount(args: {
75
92
  };
76
93
  }
77
94
 
78
- function buildRequestsLimit(payload: UmansUsagePayload, provider: string): UsageLimit | null {
95
+ function buildRequestsLimits(payload: UmansUsagePayload, provider: string): UsageLimit[] {
79
96
  const limit = toFiniteNumber(payload.limits?.requests?.limit);
97
+ const hardCap = toFiniteNumber(payload.limits?.requests?.hard_cap);
80
98
  const windowSeconds = toFiniteNumber(payload.limits?.requests?.window_seconds);
81
- const used = toFiniteNumber(payload.usage?.requests_in_window);
82
- const remaining = toFiniteNumber(payload.usage?.remaining_requests);
83
- if (limit === undefined && used === undefined) return null;
84
- const amount = buildAmount({ used, limit, remaining, unit: "requests" });
85
- // Rolling window: each request ages out `window_seconds` after it fired, so
86
- // there is no single reset timestamp. Surface the window size + label only.
87
- // `window.id` is `"5h"` to match the status-line usage segment's window-id
88
- // contract (it only recognizes `"5h"`/`"7d"`); `label` stays human-readable.
99
+ const rawUsed = toFiniteNumber(payload.usage?.requests_in_window);
100
+ const rawRemaining = toFiniteNumber(payload.usage?.remaining_requests);
101
+ const weightedUsed = toFiniteNumber(payload.usage?.weighted_in_window);
102
+ const weightedRemaining = toFiniteNumber(payload.usage?.weighted_remaining_requests);
103
+ if (limit === undefined && rawUsed === undefined && weightedUsed === undefined) return [];
104
+
105
+ // The 5h window is rolling (FIFO: each request ages out five hours after it
106
+ // fired), but the payload still reports an absolute `resets_at` for the
107
+ // current window epoch — surface it as an incremental countdown (`tick`)
108
+ // rather than a hard reset. `window.id` is `"5h"` to match the status-line
109
+ // usage segment's window-id contract (it only recognizes `"5h"`/`"7d"`).
110
+ let resetsAt: number | undefined;
111
+ if (payload.window?.resets_at) {
112
+ const parsed = Date.parse(payload.window.resets_at);
113
+ resetsAt = Number.isNaN(parsed) ? undefined : parsed;
114
+ }
89
115
  const window: UsageWindow = {
90
116
  id: "5h",
91
117
  label: "rolling 5h",
92
118
  durationMs: windowSeconds ? windowSeconds * 1000 : 5 * HOUR_MS,
119
+ ...(resetsAt !== undefined ? { resetsAt, resetLabel: "tick" } : {}),
93
120
  };
94
- return {
95
- id: "umans:requests",
96
- label: "Requests (rolling 5h)",
97
- scope: { provider, windowId: window.id, shared: true },
98
- window,
99
- amount,
100
- status: resolveStatus(amount.usedFraction),
101
- };
121
+
122
+ // Single row: either payloads without weighted counters (legacy) or payloads
123
+ // that report weighted usage but no burst ceiling (`hard_cap`). Without a
124
+ // burst ceiling there is no hard row to defer exhaustion to, so the
125
+ // authoritative counter — weighted when available, else raw — drives the
126
+ // single row and CAN exhaust at the limit. Raw burst traffic above the
127
+ // limit still never drives exhaustion on its own: weighted headroom stays
128
+ // decisive (https://github.com/can1357/oh-my-pi/issues/7858).
129
+ if (weightedUsed === undefined || hardCap === undefined) {
130
+ const amount = buildAmount({
131
+ used: weightedUsed ?? rawUsed,
132
+ limit,
133
+ remaining: weightedUsed !== undefined ? weightedRemaining : rawRemaining,
134
+ unit: "requests",
135
+ });
136
+ return [
137
+ {
138
+ id: "umans:requests",
139
+ label: "Requests (rolling 5h)",
140
+ scope: { provider, windowId: window.id, shared: true },
141
+ window,
142
+ amount,
143
+ status: resolveStatus(amount.usedFraction),
144
+ },
145
+ ];
146
+ }
147
+
148
+ // Umans weights requests by model ("effective requests": umans-flash counts
149
+ // 0.5), so the weighted counters are the authoritative utilization against
150
+ // the soft `limit`; the raw counters include burst/superseded traffic and
151
+ // read as exhausted mid-window while the account still has weighted
152
+ // headroom (https://github.com/can1357/oh-my-pi/issues/7858). Soft cap hits
153
+ // warn; only the burst ceiling (`hard_cap`, raw counts) can exhaust.
154
+ const softAmount = buildAmount({ used: weightedUsed, limit, remaining: weightedRemaining, unit: "requests" });
155
+ const limits: UsageLimit[] = [
156
+ {
157
+ id: "umans:requests:soft",
158
+ label: "Requests (soft cap)",
159
+ scope: { provider, windowId: window.id, shared: true },
160
+ window,
161
+ amount: softAmount,
162
+ status: softCapStatus(softAmount.usedFraction),
163
+ },
164
+ ];
165
+ if (hardCap !== undefined && rawUsed !== undefined) {
166
+ const hardAmount = buildAmount({ used: rawUsed, limit: hardCap, remaining: undefined, unit: "requests" });
167
+ limits.push({
168
+ id: "umans:requests:hard",
169
+ label: "Requests (burst ceiling)",
170
+ scope: { provider, windowId: window.id, shared: true },
171
+ window,
172
+ amount: hardAmount,
173
+ status: resolveStatus(hardAmount.usedFraction),
174
+ });
175
+ }
176
+ return limits;
102
177
  }
103
178
 
104
179
  function buildConcurrencyLimit(payload: UmansUsagePayload, provider: string): UsageLimit | null {
@@ -157,9 +232,7 @@ async function fetchUmansUsage(params: UsageFetchParams, ctx: UsageFetchContext)
157
232
  return null;
158
233
  }
159
234
 
160
- const limits: UsageLimit[] = [];
161
- const requests = buildRequestsLimit(payload, params.provider);
162
- if (requests) limits.push(requests);
235
+ const limits: UsageLimit[] = [...buildRequestsLimits(payload, params.provider)];
163
236
  const concurrency = buildConcurrencyLimit(payload, params.provider);
164
237
  if (concurrency) limits.push(concurrency);
165
238
  if (limits.length === 0) return null;
@@ -25,7 +25,7 @@
25
25
  * events are forwarded verbatim.
26
26
  */
27
27
 
28
- import { isAnthropicWebSearchHistoryBlock } from "../providers/anthropic-wire";
28
+ import { isAnthropicServerToolHistoryBlock } from "../providers/anthropic-wire";
29
29
  import type {
30
30
  AnthropicServerToolContent,
31
31
  AssistantMessage,
@@ -430,7 +430,7 @@ class LeakedThinkingProjector {
430
430
  const pairedIndexes = new Set<number>();
431
431
  for (let srcIndex = 0; srcIndex < message.content.length; srcIndex++) {
432
432
  const content = message.content[srcIndex];
433
- if (content?.type !== "anthropicServerTool" || !isAnthropicWebSearchHistoryBlock(content.block)) continue;
433
+ if (content?.type !== "anthropicServerTool" || !isAnthropicServerToolHistoryBlock(content.block)) continue;
434
434
  if (content.block.type === "server_tool_use") {
435
435
  pendingCalls.set(content.block.id, srcIndex);
436
436
  continue;
@@ -7,8 +7,18 @@
7
7
  * copied onto a `type: "null"` branch, or an `enum` placed on an `array`
8
8
  * schema instead of its `items`). One such tool 400s the entire turn, so
9
9
  * callers quarantine just the offending tool. See issue #2652.
10
+ *
11
+ * xAI additionally rejects a leftover *root* `anyOf`/`oneOf` whose branches
12
+ * are not objects ("tool parameter root must be an object type"). That class
13
+ * is opt-in via {@link FindStrictToolSchemaViolationOptions.rejectXaiRootObjectUnion}
14
+ * so OpenAI/Azure/Codex keep valid object-root unions.
10
15
  */
11
16
 
17
+ export interface FindStrictToolSchemaViolationOptions {
18
+ /** xAI (paid + OAuth) only: leftover object-root unions 400 the whole turn. */
19
+ rejectXaiRootObjectUnion?: boolean;
20
+ }
21
+
12
22
  type JsonRecord = Record<string, unknown>;
13
23
 
14
24
  const SCHEMA_TYPE_NAMES: Record<string, true> = {
@@ -70,10 +80,14 @@ const CHILD_ARRAY_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"] as const;
70
80
  * contradictions. Returns a JSON-pointer-ish path to the first offending node,
71
81
  * or `null` when the schema is safe to emit.
72
82
  */
73
- export function findStrictToolSchemaViolation(schema: unknown, path = "#"): string | null {
83
+ export function findStrictToolSchemaViolation(
84
+ schema: unknown,
85
+ path = "#",
86
+ options?: FindStrictToolSchemaViolationOptions,
87
+ ): string | null {
74
88
  if (Array.isArray(schema)) {
75
89
  for (let i = 0; i < schema.length; i++) {
76
- const hit = findStrictToolSchemaViolation(schema[i], `${path}/${i}`);
90
+ const hit = findStrictToolSchemaViolation(schema[i], `${path}/${i}`, options);
77
91
  if (hit) return hit;
78
92
  }
79
93
  return null;
@@ -91,25 +105,45 @@ export function findStrictToolSchemaViolation(schema: unknown, path = "#"): stri
91
105
  }
92
106
  }
93
107
 
108
+ // xAI rejects the whole request when the *root* schema is typed as object
109
+ // (or has properties) AND still carries an anyOf/oneOf with a typeless or
110
+ // non-object branch. Nested unions and pure root unions are not this error.
111
+ if (
112
+ options?.rejectXaiRootObjectUnion &&
113
+ path === "#" &&
114
+ (types.includes("object") || (node.properties !== undefined && typeof node.properties === "object"))
115
+ ) {
116
+ for (const key of ["anyOf", "oneOf"] as const) {
117
+ const arr = node[key];
118
+ if (!Array.isArray(arr) || arr.length === 0) continue;
119
+ const hasNonObjectBranch = arr.some(branch => {
120
+ if (typeof branch !== "object" || branch === null || Array.isArray(branch)) return true;
121
+ const branchTypes = declaredTypes(branch as JsonRecord);
122
+ return !branchTypes.includes("object");
123
+ });
124
+ if (hasNonObjectBranch) return `${path}/${key}`;
125
+ }
126
+ }
127
+
94
128
  for (const key of CHILD_MAP_KEYS) {
95
129
  const sub = node[key];
96
130
  if (sub && typeof sub === "object" && !Array.isArray(sub)) {
97
131
  for (const k of Object.keys(sub as JsonRecord)) {
98
- const hit = findStrictToolSchemaViolation((sub as JsonRecord)[k], `${path}/${key}/${k}`);
132
+ const hit = findStrictToolSchemaViolation((sub as JsonRecord)[k], `${path}/${key}/${k}`, options);
99
133
  if (hit) return hit;
100
134
  }
101
135
  }
102
136
  }
103
137
  for (const key of CHILD_SCHEMA_KEYS) {
104
138
  if (key in node) {
105
- const hit = findStrictToolSchemaViolation(node[key], `${path}/${key}`);
139
+ const hit = findStrictToolSchemaViolation(node[key], `${path}/${key}`, options);
106
140
  if (hit) return hit;
107
141
  }
108
142
  }
109
143
  for (const key of CHILD_ARRAY_KEYS) {
110
144
  const arr = node[key];
111
145
  if (Array.isArray(arr)) {
112
- const hit = findStrictToolSchemaViolation(arr, `${path}/${key}`);
146
+ const hit = findStrictToolSchemaViolation(arr, `${path}/${key}`, options);
113
147
  if (hit) return hit;
114
148
  }
115
149
  }
@@ -204,6 +204,37 @@ function rewriteNullableScalarAnyOf(schema: Record<string, unknown>): void {
204
204
  schema.type = [scalarType, "null"];
205
205
  }
206
206
 
207
+ function isExclusiveRequiredBranch(branch: unknown): boolean {
208
+ if (!isSchemaRecord(branch)) return false;
209
+ if (Object.hasOwn(branch, "type")) return false;
210
+ if (!Array.isArray(branch.required) || branch.required.length === 0) return false;
211
+ if (!branch.required.every(name => typeof name === "string" && name.length > 0)) return false;
212
+ for (const key in branch) {
213
+ if (!Object.hasOwn(branch, key)) continue;
214
+ if (key === "required" || key === "description" || key === "title") continue;
215
+ return false;
216
+ }
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * Return an xAI-compatible copy of an object-root schema whose union consists
222
+ * only of typeless required-key fragments. Other providers must retain the
223
+ * union because it is a real model-facing constraint.
224
+ */
225
+ export function flattenExclusiveRequiredRootUnion(schema: Record<string, unknown>): Record<string, unknown> {
226
+ const unionKey = Array.isArray(schema.anyOf) ? "anyOf" : Array.isArray(schema.oneOf) ? "oneOf" : undefined;
227
+ if (!unionKey) return schema;
228
+ const union = schema[unionKey];
229
+ if (!Array.isArray(union) || union.length === 0) return schema;
230
+ const typedObject = schema.type === "object" || (Array.isArray(schema.type) && schema.type.includes("object"));
231
+ if (!typedObject && !isSchemaRecord(schema.properties)) return schema;
232
+ if (!union.every(isExclusiveRequiredBranch)) return schema;
233
+ const flattened = { ...schema };
234
+ delete flattened[unionKey];
235
+ return flattened;
236
+ }
237
+
207
238
  /** Keys whose values are a single JSON Schema (not an array or map). */
208
239
  const SCHEMA_VALUE_KEYS = [
209
240
  "additionalProperties",
@@ -8,15 +8,17 @@
8
8
  * or answering. The runaway is *not* byte-identical, so a cheap verbatim
9
9
  * tail-repeat check alone misses it.
10
10
  *
11
- * This guard watches the streamed `thinking` deltas and, on a match, terminates
12
- * the stream with a synthetic `error` {@link AssistantMessage} that carries
13
- * **no observable content**. An empty-content `stopReason: "error"` message tagged
14
- * with `AIError.Flag.ThinkingLoop` lets result consumers and `AgentSession` discard
15
- * the runaway and re-sample instead of committing garbage transcript.
11
+ * This guard watches streamed deltas and, on a match, terminates the stream with
12
+ * a synthetic `error` {@link AssistantMessage} whose terminal content is empty.
13
+ * Deltas emitted before enough evidence accumulates may already be observable to
14
+ * a live streaming consumer; the empty terminal prevents the failed attempt from
15
+ * being committed or replayed. Tagged with `AIError.Flag.ThinkingLoop`, the
16
+ * result lets `AgentSession` discard the runaway and re-sample.
16
17
  *
17
- * Three failure shapes are detected:
18
- * 1. **Verbatim tail repetition** — a short unit repeated back-to-back (e.g.
19
- * "🌊 🌊 🌊 …"). Caught from a rolling 250-char tail.
18
+ * Four failure shapes are detected:
19
+ * 1. **Exact suffix cycles** — a byte-identical unit repeated back-to-back,
20
+ * including long cycles such as the observed 311-character Kiro runaway.
21
+ * This bounded detector applies to every model.
20
22
  * 2. **Near-duplicate segments** — paragraphs that normalize to the same
21
23
  * word-trigram fingerprint. Caught with a Jaccard window over recent
22
24
  * paragraphs. Thresholds were calibrated on a real loop transcript plus
@@ -28,13 +30,16 @@
28
30
  * vocabulary and name nothing concrete. Caught by a run of low-novelty,
29
31
  * anchor-free segments; a segment naming a path/identifier resets the run, so
30
32
  * genuine but vocabulary-repetitive work (per-file templates) is spared.
33
+ * 4. **Gemini summary-header runaway** — handled separately by
34
+ * {@link GeminiHeaderRunDetector}.
31
35
  *
32
- * Scope is narrow: guarded Gemini, DeepSeek, and Grok family streams before any tool call. Native
33
- * thinking is checked first; assistant text can also be checked for providers
34
- * that surface reasoning as visible prose. On a hit the failed turn is emitted as
35
- * an empty retryable stream-stall error; result-awaiting callers (`complete`,
36
- * `completeSimple`) re-sample it a few times and then let a stubborn loop cook
37
- * through one unguarded pass. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`.
36
+ * Scope: exact cycles are guarded for every model; semantic heuristics remain
37
+ * limited to Gemini, DeepSeek, and Grok family streams before any tool call.
38
+ * Native thinking is checked first; assistant text can also be checked for
39
+ * providers that surface reasoning as visible prose. On a hit the failed turn is
40
+ * emitted as an empty retryable stream-stall error; result-awaiting callers
41
+ * (`complete`, `completeSimple`) re-sample at most three guarded attempts and
42
+ * then fail closed. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`.
38
43
  */
39
44
  import { modelFamilyToken } from "@oh-my-pi/pi-catalog/identity";
40
45
  import { logger } from "@oh-my-pi/pi-utils";
@@ -47,12 +52,17 @@ import { AssistantMessageEventStream } from "./event-stream";
47
52
  * classifiers treat it as a transient (retryable) stop without bespoke rules. */
48
53
  export const THINKING_LOOP_ERROR_MARKER = "Thinking loop detected";
49
54
 
50
- /** Rolling tail (chars) inspected for verbatim back-to-back repetition. */
51
- const VERBATIM_TAIL_WINDOW = 250;
52
- /** Minimum total repeated chars before a verbatim run counts as a loop. */
53
- const VERBATIM_MIN_REPEATED_CHARS = 180;
54
- /** Longest unit length probed for a verbatim repeat. */
55
- const VERBATIM_MAX_UNIT = 60;
55
+ /** Rolling tail retained for exact suffix-cycle detection. */
56
+ const EXACT_TAIL_WINDOW = 4096;
57
+ /** Longest exact cycle length considered. */
58
+ const EXACT_MAX_UNIT = 1024;
59
+ /** New characters between scans. Large deltas are scanned immediately. */
60
+ const EXACT_CHECK_STRIDE = 128;
61
+ /** Short cycles need four repeats covering at least this many characters. */
62
+ const EXACT_SHORT_MAX_UNIT = 60;
63
+ const EXACT_SHORT_MIN_REPEATED_CHARS = 180;
64
+ /** Long cycles need at least three repeats covering at least this many chars. */
65
+ const EXACT_LONG_MIN_REPEATED_CHARS = 1024;
56
66
 
57
67
  /** Char cap for an unterminated segment; forces a flush so a wall-of-text loop
58
68
  * (no blank lines / headings) still segments. */
@@ -96,11 +106,12 @@ const CONCRETE_ANCHOR =
96
106
  /`[^`]+`|\b\w{2,}\.[a-zA-Z]\w{0,4}\b|[\w-]+(?:\/[\w-]+){2,}|\b\w+_\w+\b|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/g;
97
107
 
98
108
  /**
99
- * True when `model.id` belongs to a family guarded for thinking/response loops:
100
- * Gemini, DeepSeek, or Grok.
109
+ * True when `model.id` belongs to a family guarded by the semantic loop
110
+ * heuristics: Gemini, DeepSeek, or Grok. Exact suffix-cycle detection applies to
111
+ * every enabled model independently of this predicate.
101
112
  *
102
113
  * Model identity is derived only from its id; provider and compatibility metadata
103
- * do not opt opaque aliases into the guard.
114
+ * do not opt opaque aliases into semantic detection.
104
115
  */
105
116
  export function isLoopGuardedModel(model: Model<Api>, options?: StreamOptions): boolean {
106
117
  if (options?.loopGuard?.enabled === false) return false;
@@ -120,8 +131,10 @@ export function isLoopGuardedModel(model: Model<Api>, options?: StreamOptions):
120
131
  * is responsible for stopping after the first hit.
121
132
  */
122
133
  export class ThinkingLoopDetector {
123
- /** Rolling char tail for verbatim repeat detection. */
134
+ /** Rolling char tail for exact suffix-cycle detection. */
124
135
  #tail = "";
136
+ /** Total characters received when the exact detector last scanned. */
137
+ #exactScannedAt = 0;
125
138
  /** Pending thinking text not yet split into completed segments. */
126
139
  #pending = "";
127
140
  /** Fingerprints of the most recent substantial segments (≤ SEGMENT_WINDOW). */
@@ -138,18 +151,27 @@ export class ThinkingLoopDetector {
138
151
  * path/identifier every paragraph is still caught. */
139
152
  #anchorWindow: Set<string>[] = [];
140
153
 
154
+ constructor(private readonly semanticHeuristics = true) {}
155
+
141
156
  push(delta: string): string | null {
142
157
  if (!delta) return null;
143
158
 
144
- // 1. Verbatim back-to-back repetition over the rolling tail.
159
+ // 1. Exact suffix cycles. Scan at a bounded cadence rather than doing
160
+ // quadratic work for every token-sized delta.
145
161
  this.#tail += delta;
146
- if (this.#tail.length > VERBATIM_TAIL_WINDOW) this.#tail = this.#tail.slice(-VERBATIM_TAIL_WINDOW);
147
- const verbatim = detectVerbatimRepetition(this.#tail);
148
- if (verbatim) {
149
- const [unit, times] = verbatim;
150
- return `repeated "${unit.trim()}" ${times}× back-to-back`;
162
+ if (this.#tail.length > EXACT_TAIL_WINDOW) this.#tail = this.#tail.slice(-EXACT_TAIL_WINDOW);
163
+ this.#exactScannedAt += delta.length;
164
+ if (this.#exactScannedAt >= EXACT_CHECK_STRIDE || delta.length >= EXACT_CHECK_STRIDE) {
165
+ this.#exactScannedAt = 0;
166
+ const exact = detectExactSuffixCycle(this.#tail);
167
+ if (exact) {
168
+ const [unit, times] = exact;
169
+ return `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
170
+ }
151
171
  }
152
172
 
173
+ if (!this.semanticHeuristics) return null;
174
+
153
175
  // 2. Near-duplicate paragraph loop. Append, then drain completed segments.
154
176
  this.#pending += delta;
155
177
  while (true) {
@@ -179,7 +201,14 @@ export class ThinkingLoopDetector {
179
201
  * terminator). Called when the thinking block ends so the final segment —
180
202
  * which may be the one that completes a duplicate cluster — is not dropped. */
181
203
  flush(): string | null {
182
- if (!this.#pending) return null;
204
+ // A stream can end before the next cadence boundary. Force one final exact
205
+ // check even when semantic heuristics are disabled and #pending is empty.
206
+ const exact = detectExactSuffixCycle(this.#tail);
207
+ if (exact) {
208
+ const [unit, times] = exact;
209
+ return `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
210
+ }
211
+ if (!this.semanticHeuristics || !this.#pending) return null;
183
212
  let rest = this.#pending;
184
213
  this.#pending = "";
185
214
  while (rest.length > 0) {
@@ -349,8 +378,9 @@ export function guardThinkingLoopStream(
349
378
  options?: StreamOptions,
350
379
  ): AssistantMessageEventStream {
351
380
  const outer = new AssistantMessageEventStream();
352
- const thinkingDetector = new ThinkingLoopDetector();
353
- const textDetector = new ThinkingLoopDetector();
381
+ const semanticHeuristics = isLoopGuardedModel(model, options);
382
+ const thinkingDetector = new ThinkingLoopDetector(semanticHeuristics);
383
+ const textDetector = new ThinkingLoopDetector(semanticHeuristics);
354
384
  const checkAssistantContent = options?.loopGuard?.checkAssistantContent !== false;
355
385
 
356
386
  void (async () => {
@@ -415,12 +445,12 @@ export function guardThinkingLoopStream(
415
445
  }
416
446
 
417
447
  /**
418
- * Apply the loop guard around a provider dispatch. For non-guarded models
419
- * (or when disabled) this is a transparent pass-through. For guarded models it injects a
420
- * guard abort signal into the provider call so a detected loop tears down the
421
- * upstream, then wraps the returned stream. The guard only raises the retryable
422
- * stall; bounding the re-samples and the final cook pass lives in the
423
- * result-awaiting caller.
448
+ * Apply the loop guard around a provider dispatch. Unless explicitly disabled,
449
+ * every model gets exact suffix-cycle detection; Gemini, DeepSeek, and Grok also
450
+ * get the semantic heuristics selected by {@link isLoopGuardedModel}. The guard
451
+ * injects an abort signal into the provider call so a detected loop tears down
452
+ * the upstream, then wraps the returned stream. Bounding result-path re-samples
453
+ * lives in the result-awaiting caller.
424
454
  */
425
455
  export function withThinkingLoopGuard<
426
456
  O extends { signal?: AbortSignal; loopGuard?: { enabled?: boolean; checkAssistantContent?: boolean } },
@@ -429,7 +459,7 @@ export function withThinkingLoopGuard<
429
459
  options: O | undefined,
430
460
  dispatch: (options: O | undefined) => AssistantMessageEventStream,
431
461
  ): AssistantMessageEventStream {
432
- if (process.env.PI_NO_THINKING_LOOP_GUARD === "1" || !isLoopGuardedModel(model, options)) {
462
+ if (process.env.PI_NO_THINKING_LOOP_GUARD === "1" || options?.loopGuard?.enabled === false) {
433
463
  return dispatch(options);
434
464
  }
435
465
  const controller = new AbortController();
@@ -467,31 +497,34 @@ function buildThinkingLoopError(model: Model<Api>, detail: string): AssistantMes
467
497
  }
468
498
 
469
499
  /**
470
- * Detect a short unit repeated back-to-back at the tail (verbatim loop). Only a
471
- * unit carrying a letter or pictographic emoji counts — runs of digits,
472
- * whitespace or punctuation are legitimate in tabular / hex / numeric output.
500
+ * Detect an exact cycle at the text suffix. A Z-array over the reversed tail
501
+ * finds every possible suffix period in linear time without substring churn.
502
+ * Short cycles retain the original 180-character/four-repeat sensitivity; long
503
+ * cycles require at least three repeats and 1024 repeated characters.
473
504
  */
474
- function detectVerbatimRepetition(text: string): [unit: string, count: number] | null {
475
- if (text.length < VERBATIM_MIN_REPEATED_CHARS) return null;
476
- const windowSize = Math.min(text.length, VERBATIM_TAIL_WINDOW);
477
- const searchSpace = text.slice(-windowSize);
478
-
479
- for (let len = 2; len <= VERBATIM_MAX_UNIT; len++) {
480
- if (searchSpace.length < len * 4) continue;
481
- const unit = searchSpace.slice(-len);
482
- if (!/[\p{L}\p{Extended_Pictographic}]/u.test(unit)) continue;
483
-
484
- let count = 0;
485
- let pos = searchSpace.length;
486
- while (pos >= len) {
487
- if (searchSpace.slice(pos - len, pos) === unit) {
488
- count++;
489
- pos -= len;
490
- } else {
491
- break;
492
- }
505
+ function detectExactSuffixCycle(text: string): [unit: string, count: number] | null {
506
+ if (text.length < EXACT_SHORT_MIN_REPEATED_CHARS) return null;
507
+ const reversed = text.split("").reverse().join("");
508
+ const z = new Uint16Array(reversed.length);
509
+ let left = 0;
510
+ let right = 0;
511
+ for (let i = 1; i < reversed.length; i++) {
512
+ if (i <= right) z[i] = Math.min(right - i + 1, z[i - left]);
513
+ while (i + z[i] < reversed.length && reversed[z[i]] === reversed[i + z[i]]) z[i]++;
514
+ if (i + z[i] - 1 > right) {
515
+ left = i;
516
+ right = i + z[i] - 1;
493
517
  }
494
- if (count >= 4 && len * count >= VERBATIM_MIN_REPEATED_CHARS) return [unit, count];
518
+ }
519
+
520
+ const maxUnit = Math.min(EXACT_MAX_UNIT, Math.floor(reversed.length / 3));
521
+ for (let len = 2; len <= maxUnit; len++) {
522
+ const count = 1 + Math.floor(z[len] / len);
523
+ const minCount = len <= EXACT_SHORT_MAX_UNIT ? 4 : 3;
524
+ const minChars = len <= EXACT_SHORT_MAX_UNIT ? EXACT_SHORT_MIN_REPEATED_CHARS : EXACT_LONG_MIN_REPEATED_CHARS;
525
+ if (count < minCount || len * count < minChars) continue;
526
+ const unit = text.slice(-len);
527
+ if (/\p{L}|\p{Extended_Pictographic}/u.test(unit)) return [unit, count];
495
528
  }
496
529
  return null;
497
530
  }