@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.
- package/CHANGELOG.md +30 -0
- package/dist/types/auth-storage.d.ts +12 -2
- package/dist/types/error/rate-limit.d.ts +5 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +89 -0
- package/dist/types/providers/anthropic-wire.d.ts +18 -5
- package/dist/types/providers/openai-shared.d.ts +1 -1
- package/dist/types/types.d.ts +5 -4
- package/dist/types/usage/kimi.d.ts +3 -1
- package/dist/types/utils/schema/strict-tool-validation.d.ts +10 -1
- package/dist/types/utils/schema/wire.d.ts +6 -0
- package/dist/types/utils/thinking-loop.d.ts +12 -9
- package/package.json +5 -5
- package/src/auth-storage.ts +36 -5
- package/src/error/flags.ts +6 -2
- package/src/error/rate-limit.ts +31 -2
- package/src/index.ts +1 -0
- package/src/oneshot-retry.ts +230 -0
- package/src/providers/anthropic-messages-server.ts +5 -5
- package/src/providers/anthropic-wire.ts +31 -5
- package/src/providers/anthropic.ts +15 -4
- package/src/providers/cursor.ts +44 -4
- package/src/providers/openai-completions.ts +107 -63
- package/src/providers/openai-responses.ts +13 -10
- package/src/providers/openai-shared.ts +10 -3
- package/src/registry/alibaba-coding-plan.ts +2 -2
- package/src/registry/oauth/kimi.ts +20 -0
- package/src/stream.ts +59 -57
- package/src/types.ts +5 -4
- package/src/usage/kimi.ts +15 -0
- package/src/usage/umans.ts +93 -20
- package/src/utils/leaked-thinking-stream.ts +2 -2
- package/src/utils/schema/strict-tool-validation.ts +39 -5
- package/src/utils/schema/wire.ts +31 -0
- 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
|
+
};
|
package/src/usage/umans.ts
CHANGED
|
@@ -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
|
|
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
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 {
|
|
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" || !
|
|
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(
|
|
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
|
}
|
package/src/utils/schema/wire.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
* 1. **
|
|
19
|
-
*
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
51
|
-
const
|
|
52
|
-
/**
|
|
53
|
-
const
|
|
54
|
-
/**
|
|
55
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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 >
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
const
|
|
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.
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
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" ||
|
|
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
|
|
471
|
-
*
|
|
472
|
-
*
|
|
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
|
|
475
|
-
if (text.length <
|
|
476
|
-
const
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
}
|