@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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { extractRetryHint } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import * as AIError from "./error";
|
|
3
|
+
import type { AssistantMessage } from "./types";
|
|
4
|
+
import { getHeadersFromError, getRetryAfterMsFromHeaders, type HeadersLike } from "./utils/retry-after";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Transient-failure retry for **oneshot** (non-agent-loop) completions.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: `streamSimple`/`completeSimple` retry *auth* failures
|
|
10
|
+
* (credential rotation) but deliberately surface *transient* provider failures
|
|
11
|
+
* — Anthropic `overloaded_error`, `rate_limit_error`, HTTP 429/500/502/503/529
|
|
12
|
+
* — as a **resolved** `AssistantMessage` with `stopReason: "error"`. For the
|
|
13
|
+
* main agent turn that is correct: `TurnRecovery` owns recovery there, and it
|
|
14
|
+
* must refuse to replay once tool calls or visible text have streamed.
|
|
15
|
+
*
|
|
16
|
+
* Oneshots have no such hazard. A summary, title, handoff, or image
|
|
17
|
+
* description produces no side effects, so re-issuing the whole request is
|
|
18
|
+
* safe and is almost always what the caller wants. Before this helper every
|
|
19
|
+
* oneshot call site had to re-implement that decision, and most did not —
|
|
20
|
+
* failing on the first blip, or swallowing it into `null` so a transient
|
|
21
|
+
* overload was indistinguishable from a legitimate empty result.
|
|
22
|
+
*
|
|
23
|
+
* Classification reuses the existing provider predicates (`AIError`), so the
|
|
24
|
+
* set of retryable Anthropic failures stays defined in exactly one place.
|
|
25
|
+
* Usage limits are included: unlike the provider loop — which excludes them so
|
|
26
|
+
* credential rotation can own them — a oneshot has no rotation layer above it,
|
|
27
|
+
* and the retry hint the provider supplies (`retry-after`, "try again in ~5m")
|
|
28
|
+
* is honored, so waiting is the correct response.
|
|
29
|
+
*/
|
|
30
|
+
export interface OneshotRetryOptions {
|
|
31
|
+
/** Total attempts, including the first. Default 3. Values < 1 are treated as 1. */
|
|
32
|
+
maxAttempts?: number;
|
|
33
|
+
/** First backoff step in ms; doubles per attempt. Default 500. */
|
|
34
|
+
baseDelayMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Upper bound for a single wait. Default 30_000. A provider retry hint
|
|
37
|
+
* longer than this aborts the retry instead of parking the caller — the
|
|
38
|
+
* error surfaces so higher-level recovery (or the user) can decide.
|
|
39
|
+
*/
|
|
40
|
+
maxDelayMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Stops further attempts. Two distinct paths, both preserving the caller's
|
|
43
|
+
* intent: an abort already visible when an attempt settles surfaces that
|
|
44
|
+
* attempt's own result (`completeSimple` reports `stopReason: "aborted"`),
|
|
45
|
+
* while an abort that lands during the backoff wait rejects with the abort
|
|
46
|
+
* reason — a user cancel stays a cancel and is never relabelled as the
|
|
47
|
+
* provider failure we happened to be waiting on.
|
|
48
|
+
*
|
|
49
|
+
* This helper does NOT pass the signal into `run` — cancelling the in-flight
|
|
50
|
+
* request is the closure's job, because a per-attempt deadline must be
|
|
51
|
+
* rebuilt on every attempt. Construct it inside `run`
|
|
52
|
+
* (`signal: AbortSignal.timeout(MS)`, or `AbortSignal.any([outer, perAttempt])`);
|
|
53
|
+
* a deadline captured outside would fire once and then abort every retry,
|
|
54
|
+
* silently turning this helper into a single attempt.
|
|
55
|
+
*/
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
/**
|
|
58
|
+
* Headers of the attempt that just failed, used to honor `retry-after`.
|
|
59
|
+
*
|
|
60
|
+
* Load-bearing: a transient Anthropic failure arrives as a **resolved**
|
|
61
|
+
* `AssistantMessage`, and `AssistantMessage` carries no headers — so without
|
|
62
|
+
* this the real `retry-after` / `x-ratelimit-reset` values on a 429/529 are
|
|
63
|
+
* invisible and only the (usually hint-free) error text is available.
|
|
64
|
+
* Callers that already capture headers via `SimpleStreamOptions.onResponse`
|
|
65
|
+
* should return the latest capture here; it is read once per failed attempt.
|
|
66
|
+
* Thrown errors need no wiring — headers are recovered from the error itself.
|
|
67
|
+
*/
|
|
68
|
+
getResponseHeaders?: () => HeadersLike;
|
|
69
|
+
/** Observability hook. Fires immediately before sleeping. */
|
|
70
|
+
onRetry?: (info: OneshotRetryInfo) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OneshotRetryInfo {
|
|
74
|
+
/** 1-based index of the attempt that just failed. */
|
|
75
|
+
attempt: number;
|
|
76
|
+
maxAttempts: number;
|
|
77
|
+
delayMs: number;
|
|
78
|
+
/** True when `delayMs` came from a provider retry hint rather than backoff. */
|
|
79
|
+
fromRetryHint: boolean;
|
|
80
|
+
errorMessage: string;
|
|
81
|
+
/** `AIError` classification bits of the failure. */
|
|
82
|
+
errorId: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
86
|
+
const DEFAULT_BASE_DELAY_MS = 500;
|
|
87
|
+
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
88
|
+
/** Cap on pure backoff growth. A provider hint may still exceed this, up to `maxDelayMs`. */
|
|
89
|
+
const BACKOFF_CEILING_MS = 8_000;
|
|
90
|
+
const RETRY_AFTER_MS_SUFFIX = /(?:^|\s)retry-after-ms=([0-9]+(?:\.[0-9]+)?)(?=\s|$)/i;
|
|
91
|
+
|
|
92
|
+
function backoffDelayMs(attempt: number, baseDelayMs: number): number {
|
|
93
|
+
const growth = Math.min(baseDelayMs * 2 ** (attempt - 1), BACKOFF_CEILING_MS);
|
|
94
|
+
// 75-100% jitter, matching the provider loop and TurnRecovery, so a fleet of
|
|
95
|
+
// concurrent oneshots does not re-converge on the same instant.
|
|
96
|
+
return Math.round(growth * (0.75 + Math.random() * 0.25));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Retryable when the provider says transient, or when it says "wait, then retry". */
|
|
100
|
+
function isRetryableOneshotFailure(errorId: number, errorStatus: number | undefined, errorMessage: string): boolean {
|
|
101
|
+
// llama.cpp reports deterministic tool-call JSON parse failures as HTTP 500.
|
|
102
|
+
// Replaying the same prompt produces the same malformed output.
|
|
103
|
+
if (AIError.LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(errorMessage)) return false;
|
|
104
|
+
if (AIError.is(errorId, AIError.Flag.ContentBlocked)) return false;
|
|
105
|
+
return (
|
|
106
|
+
AIError.isTransientStatus(errorStatus) ||
|
|
107
|
+
AIError.is(errorId, AIError.Flag.Transient) ||
|
|
108
|
+
AIError.is(errorId, AIError.Flag.UsageLimit) ||
|
|
109
|
+
AIError.retriable(errorId)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sleep(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
114
|
+
if (delayMs <= 0) return Promise.resolve();
|
|
115
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
signal?.removeEventListener("abort", onAbort);
|
|
118
|
+
resolve();
|
|
119
|
+
}, delayMs);
|
|
120
|
+
const onAbort = () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(signal?.reason ?? new AIError.AbortError("oneshot retry aborted"));
|
|
123
|
+
};
|
|
124
|
+
if (signal) {
|
|
125
|
+
if (signal.aborted) {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
return Promise.reject(signal.reason ?? new AIError.AbortError("oneshot retry aborted"));
|
|
128
|
+
}
|
|
129
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
}
|
|
131
|
+
return promise;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run a oneshot completion, retrying transient provider failures.
|
|
136
|
+
*
|
|
137
|
+
* Handles both failure shapes: a resolved `AssistantMessage` carrying
|
|
138
|
+
* `stopReason: "error"` (what `completeSimple` produces) and a thrown error
|
|
139
|
+
* (what the raw HTTP helpers produce). A non-retryable failure is returned or
|
|
140
|
+
* rethrown unchanged, so existing caller error handling keeps working — this
|
|
141
|
+
* only removes the *first-blip* failure mode.
|
|
142
|
+
*/
|
|
143
|
+
export async function retryTransientCompletion(
|
|
144
|
+
run: (attempt: number) => Promise<AssistantMessage>,
|
|
145
|
+
options?: OneshotRetryOptions,
|
|
146
|
+
): Promise<AssistantMessage> {
|
|
147
|
+
const maxAttempts = Math.max(1, options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
148
|
+
const baseDelayMs = options?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
149
|
+
const maxDelayMs = options?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
150
|
+
const signal = options?.signal;
|
|
151
|
+
|
|
152
|
+
for (let attempt = 1; ; attempt++) {
|
|
153
|
+
let message: AssistantMessage | undefined;
|
|
154
|
+
let thrown: unknown;
|
|
155
|
+
try {
|
|
156
|
+
message = await run(attempt);
|
|
157
|
+
if (message.stopReason !== "error") return message;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
thrown = error;
|
|
160
|
+
}
|
|
161
|
+
// A caller abort is never a transient failure — surface it immediately so
|
|
162
|
+
// cancellation stays responsive.
|
|
163
|
+
if (signal?.aborted) {
|
|
164
|
+
if (thrown !== undefined) throw thrown;
|
|
165
|
+
return message as AssistantMessage;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const errorId =
|
|
169
|
+
thrown !== undefined ? AIError.classify(thrown) : AIError.classifyMessage(message as AssistantMessage);
|
|
170
|
+
if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) {
|
|
171
|
+
if (thrown !== undefined) throw thrown;
|
|
172
|
+
return message as AssistantMessage;
|
|
173
|
+
}
|
|
174
|
+
const errorMessage =
|
|
175
|
+
thrown !== undefined
|
|
176
|
+
? thrown instanceof Error
|
|
177
|
+
? thrown.message
|
|
178
|
+
: String(thrown)
|
|
179
|
+
: ((message as AssistantMessage).errorMessage ?? "unknown error");
|
|
180
|
+
const errorStatus = thrown !== undefined ? AIError.status(thrown) : (message as AssistantMessage).errorStatus;
|
|
181
|
+
const lastAttempt = attempt >= maxAttempts;
|
|
182
|
+
if (lastAttempt || !isRetryableOneshotFailure(errorId, errorStatus, errorMessage)) {
|
|
183
|
+
if (thrown !== undefined) throw thrown;
|
|
184
|
+
return message as AssistantMessage;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Headers first: a real Anthropic 429/529 carries `retry-after` /
|
|
188
|
+
// `x-ratelimit-reset*` in the response, and the resolved AssistantMessage
|
|
189
|
+
// has none — the caller supplies them via `getResponseHeaders`. Thrown
|
|
190
|
+
// errors (e.g. AnthropicApiError) carry their own headers.
|
|
191
|
+
const headers: HeadersLike = thrown !== undefined ? getHeadersFromError(thrown) : options?.getResponseHeaders?.();
|
|
192
|
+
const headerHintMs = getRetryAfterMsFromHeaders(headers);
|
|
193
|
+
const extractedTextHintMs = extractRetryHint(undefined, errorMessage);
|
|
194
|
+
const suffixValue = RETRY_AFTER_MS_SUFFIX.exec(errorMessage)?.[1];
|
|
195
|
+
const parsedSuffixMs = suffixValue === undefined ? undefined : Number(suffixValue);
|
|
196
|
+
const suffixHintMs =
|
|
197
|
+
parsedSuffixMs !== undefined && Number.isFinite(parsedSuffixMs) && parsedSuffixMs > 0
|
|
198
|
+
? Math.ceil(parsedSuffixMs)
|
|
199
|
+
: undefined;
|
|
200
|
+
const textHintMs =
|
|
201
|
+
extractedTextHintMs === undefined && suffixHintMs === undefined
|
|
202
|
+
? undefined
|
|
203
|
+
: Math.max(extractedTextHintMs ?? 0, suffixHintMs ?? 0);
|
|
204
|
+
const hintMs =
|
|
205
|
+
headerHintMs === undefined && textHintMs === undefined
|
|
206
|
+
? undefined
|
|
207
|
+
: Math.max(headerHintMs ?? 0, textHintMs ?? 0);
|
|
208
|
+
// An over-cap hint means "come back much later"; parking a oneshot that
|
|
209
|
+
// long is worse than surfacing the failure to the caller.
|
|
210
|
+
if (hintMs !== undefined && hintMs > maxDelayMs) {
|
|
211
|
+
if (thrown !== undefined) throw thrown;
|
|
212
|
+
return message as AssistantMessage;
|
|
213
|
+
}
|
|
214
|
+
const backoff = backoffDelayMs(attempt, baseDelayMs);
|
|
215
|
+
const delayMs = Math.min(Math.max(hintMs ?? 0, backoff), maxDelayMs);
|
|
216
|
+
|
|
217
|
+
options?.onRetry?.({
|
|
218
|
+
attempt,
|
|
219
|
+
maxAttempts,
|
|
220
|
+
delayMs,
|
|
221
|
+
fromRetryHint: hintMs !== undefined && hintMs >= backoff,
|
|
222
|
+
errorMessage,
|
|
223
|
+
errorId,
|
|
224
|
+
});
|
|
225
|
+
// Aborting mid-backoff rejects with the caller's abort reason: a user
|
|
226
|
+
// cancel must stay a cancel, not get relabelled as the provider failure we
|
|
227
|
+
// happened to be waiting on.
|
|
228
|
+
await sleep(delayMs, signal);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type AnthropicUserContentBlock,
|
|
28
28
|
anthropicMessagesRequestSchema,
|
|
29
29
|
} from "./anthropic-messages-server-schema";
|
|
30
|
-
import {
|
|
30
|
+
import { isAnthropicServerToolHistoryBlock } from "./anthropic-wire";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Anthropic Messages API (https://docs.anthropic.com/en/api/messages) ↔ pi-ai
|
|
@@ -216,10 +216,10 @@ function walkAssistantContent(
|
|
|
216
216
|
break;
|
|
217
217
|
case "server_tool_use":
|
|
218
218
|
case "web_search_tool_result":
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
219
|
+
case "tool_search_tool_result":
|
|
220
|
+
if (isAnthropicServerToolHistoryBlock(block)) {
|
|
221
|
+
// Anthropic requires supported server-tool call/results replayed
|
|
222
|
+
// verbatim, so retain each opaque block instead of flattening it.
|
|
223
223
|
out.push({ type: "anthropicServerTool", block: { ...block } });
|
|
224
224
|
} else {
|
|
225
225
|
// Other server tools use distinct result block types that omp
|
|
@@ -77,6 +77,11 @@ export type ServerToolUseBlockParam = {
|
|
|
77
77
|
/** Web-search server-tool call whose matching result is replayable by omp. */
|
|
78
78
|
export type WebSearchServerToolUseBlockParam = ServerToolUseBlockParam & { name: "web_search" };
|
|
79
79
|
|
|
80
|
+
/** Tool-search server-tool call whose matching result is replayable by omp. */
|
|
81
|
+
export type ToolSearchServerToolUseBlockParam = ServerToolUseBlockParam & {
|
|
82
|
+
name: "tool_search_tool_regex" | "tool_search_tool_bm25";
|
|
83
|
+
};
|
|
84
|
+
|
|
80
85
|
/** Native web-search result replayed inside an assistant turn. */
|
|
81
86
|
export type WebSearchToolResultBlockParam = {
|
|
82
87
|
type: "web_search_tool_result";
|
|
@@ -85,18 +90,37 @@ export type WebSearchToolResultBlockParam = {
|
|
|
85
90
|
[key: string]: unknown;
|
|
86
91
|
};
|
|
87
92
|
|
|
88
|
-
/**
|
|
89
|
-
export
|
|
93
|
+
/** Native tool-search result replayed inside an assistant turn. */
|
|
94
|
+
export type ToolSearchToolResultBlockParam = {
|
|
95
|
+
type: "tool_search_tool_result";
|
|
96
|
+
tool_use_id: string;
|
|
97
|
+
content: unknown;
|
|
98
|
+
[key: string]: unknown;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Anthropic server-tool history variants omp can replay atomically. */
|
|
102
|
+
export type AnthropicServerToolHistoryBlockParam =
|
|
103
|
+
| WebSearchServerToolUseBlockParam
|
|
104
|
+
| WebSearchToolResultBlockParam
|
|
105
|
+
| ToolSearchServerToolUseBlockParam
|
|
106
|
+
| ToolSearchToolResultBlockParam;
|
|
107
|
+
|
|
108
|
+
/** True when a block is complete Anthropic server-tool history omp can replay. */
|
|
109
|
+
export function isAnthropicServerToolHistoryBlock(block: {
|
|
90
110
|
type: string;
|
|
91
111
|
name?: unknown;
|
|
92
112
|
id?: unknown;
|
|
93
113
|
tool_use_id?: unknown;
|
|
94
114
|
content?: unknown;
|
|
95
|
-
}): block is
|
|
115
|
+
}): block is AnthropicServerToolHistoryBlockParam {
|
|
96
116
|
if (block.type === "server_tool_use") {
|
|
97
|
-
|
|
117
|
+
const supportedName =
|
|
118
|
+
block.name === "web_search" ||
|
|
119
|
+
block.name === "tool_search_tool_regex" ||
|
|
120
|
+
block.name === "tool_search_tool_bm25";
|
|
121
|
+
return supportedName && typeof block.id === "string" && block.id.length > 0;
|
|
98
122
|
}
|
|
99
|
-
if (block.type === "web_search_tool_result") {
|
|
123
|
+
if (block.type === "web_search_tool_result" || block.type === "tool_search_tool_result") {
|
|
100
124
|
return typeof block.tool_use_id === "string" && block.tool_use_id.length > 0 && Object.hasOwn(block, "content");
|
|
101
125
|
}
|
|
102
126
|
return false;
|
|
@@ -132,6 +156,7 @@ export type ContentBlockParam =
|
|
|
132
156
|
| ToolResultBlockParam
|
|
133
157
|
| ServerToolUseBlockParam
|
|
134
158
|
| WebSearchToolResultBlockParam
|
|
159
|
+
| ToolSearchToolResultBlockParam
|
|
135
160
|
| ThinkingBlockParam
|
|
136
161
|
| RedactedThinkingBlockParam
|
|
137
162
|
| FallbackBlockParam;
|
|
@@ -319,6 +344,7 @@ export type ResponseContentBlock =
|
|
|
319
344
|
| { type: "tool_use"; id: string; name: string; input?: Record<string, unknown> | null }
|
|
320
345
|
| ServerToolUseBlockParam
|
|
321
346
|
| WebSearchToolResultBlockParam
|
|
347
|
+
| ToolSearchToolResultBlockParam
|
|
322
348
|
| { type: "fallback"; from: { model: string }; to: { model: string } };
|
|
323
349
|
|
|
324
350
|
export type ContentBlockDelta =
|
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
type Usage as AnthropicWireUsage,
|
|
80
80
|
type ContentBlockParam,
|
|
81
81
|
type FallbackParam,
|
|
82
|
-
|
|
82
|
+
isAnthropicServerToolHistoryBlock,
|
|
83
83
|
type MessageCreateParams,
|
|
84
84
|
type MessageCreateParamsStreaming,
|
|
85
85
|
type MessageParam,
|
|
@@ -2404,7 +2404,7 @@ const streamAnthropicOnce = (
|
|
|
2404
2404
|
streamedReplayUnsafeContent = true;
|
|
2405
2405
|
const block: Block = {
|
|
2406
2406
|
type: "thinking",
|
|
2407
|
-
thinking: "",
|
|
2407
|
+
thinking: event.content_block.thinking ?? "",
|
|
2408
2408
|
thinkingSignature: "",
|
|
2409
2409
|
[kStreamingBlockIndex]: event.index,
|
|
2410
2410
|
};
|
|
@@ -2416,6 +2416,14 @@ const streamAnthropicOnce = (
|
|
|
2416
2416
|
contentIndex,
|
|
2417
2417
|
partial: output,
|
|
2418
2418
|
});
|
|
2419
|
+
if (block.thinking) {
|
|
2420
|
+
stream.push({
|
|
2421
|
+
type: "thinking_delta",
|
|
2422
|
+
contentIndex,
|
|
2423
|
+
delta: block.thinking,
|
|
2424
|
+
partial: output,
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2419
2427
|
} else if (event.content_block.type === "redacted_thinking") {
|
|
2420
2428
|
streamedReplayUnsafeContent = true;
|
|
2421
2429
|
const block: Block = {
|
|
@@ -2429,8 +2437,11 @@ const streamAnthropicOnce = (
|
|
|
2429
2437
|
kind: "redactedThinking",
|
|
2430
2438
|
});
|
|
2431
2439
|
} else if (
|
|
2432
|
-
|
|
2433
|
-
umansGatewayWebSearchHeader === undefined
|
|
2440
|
+
isAnthropicServerToolHistoryBlock(event.content_block) &&
|
|
2441
|
+
(umansGatewayWebSearchHeader === undefined ||
|
|
2442
|
+
(event.content_block.type === "server_tool_use"
|
|
2443
|
+
? event.content_block.name !== "web_search"
|
|
2444
|
+
: event.content_block.type !== "web_search_tool_result"))
|
|
2434
2445
|
) {
|
|
2435
2446
|
streamedReplayUnsafeContent = true;
|
|
2436
2447
|
const block: Block = {
|
package/src/providers/cursor.ts
CHANGED
|
@@ -293,11 +293,23 @@ const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
|
|
|
293
293
|
* model reads it and should route around the capability, not retry the call.
|
|
294
294
|
*/
|
|
295
295
|
const NOT_IMPLEMENTED_SUFFIX = "not implemented by this client";
|
|
296
|
+
/** Bare gRPC `resource_exhausted` end-streams (also inside a Connect error message). */
|
|
297
|
+
const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/i;
|
|
296
298
|
const NOT_IMPLEMENTED = `Not implemented by this client`;
|
|
297
299
|
|
|
298
300
|
const conversationStateCache = new Map<string, ConversationStateStructure>();
|
|
299
301
|
const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
|
|
300
302
|
const warnedCursorKimiK3ReplayMessages = new Set<string>();
|
|
303
|
+
/**
|
|
304
|
+
* Base conversation id → rotated wire id (#8345). Cursor's backend can pin a
|
|
305
|
+
* per-conversation rejection (bare `resource_exhausted`, zero tokens) to one
|
|
306
|
+
* conversationId forever; the session is then unusable until /fork mints a
|
|
307
|
+
* new id. On the first such failure the id is rotated once and the cached
|
|
308
|
+
* state migrates, so the retry loop's next attempt starts a fresh
|
|
309
|
+
* conversation — the same recovery /fork performs. Keyed by the base id the
|
|
310
|
+
* caller derived, so a failed rotation is never repeated.
|
|
311
|
+
*/
|
|
312
|
+
const rotatedConversationIds = new Map<string, string>();
|
|
301
313
|
|
|
302
314
|
export interface CursorOptions extends StreamOptions {
|
|
303
315
|
customSystemPrompt?: string;
|
|
@@ -587,13 +599,19 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
587
599
|
h2Completion.resolve();
|
|
588
600
|
};
|
|
589
601
|
|
|
602
|
+
// Hoisted out of the try block: the #8345 rotation in the catch path
|
|
603
|
+
// needs both ids, and the catch block cannot see try-scoped consts.
|
|
604
|
+
let baseConversationId: string | undefined;
|
|
605
|
+
let conversationId: string | undefined;
|
|
606
|
+
let usageState: UsageState | undefined;
|
|
590
607
|
try {
|
|
591
608
|
const apiKey = options?.apiKey;
|
|
592
609
|
if (!apiKey) {
|
|
593
610
|
throw new AIError.MissingApiKeyError(undefined, "Cursor API key (access token) is required");
|
|
594
611
|
}
|
|
595
612
|
|
|
596
|
-
|
|
613
|
+
baseConversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
|
|
614
|
+
conversationId = rotatedConversationIds.get(baseConversationId) ?? baseConversationId;
|
|
597
615
|
const blobStore = conversationBlobStores.get(conversationId) ?? new Map<string, Uint8Array>();
|
|
598
616
|
conversationBlobStores.set(conversationId, blobStore);
|
|
599
617
|
const cachedState = conversationStateCache.get(conversationId);
|
|
@@ -667,7 +685,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
667
685
|
let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
668
686
|
let currentToolCall: ToolCallState | null = null;
|
|
669
687
|
const resolvedMcpToolCallIds = new Set<string>();
|
|
670
|
-
|
|
688
|
+
usageState = { sawTokenDelta: false };
|
|
671
689
|
|
|
672
690
|
const state: BlockState = {
|
|
673
691
|
get currentTextBlock() {
|
|
@@ -702,7 +720,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
702
720
|
openBlockState = state;
|
|
703
721
|
|
|
704
722
|
const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
|
|
705
|
-
conversationStateCache.set(conversationId
|
|
723
|
+
conversationStateCache.set(conversationId!, checkpoint);
|
|
706
724
|
};
|
|
707
725
|
|
|
708
726
|
h2Request.on("response", headers => {
|
|
@@ -759,7 +777,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
759
777
|
h2Request!,
|
|
760
778
|
options?.execHandlers,
|
|
761
779
|
options?.onToolResult,
|
|
762
|
-
usageState
|
|
780
|
+
usageState!,
|
|
763
781
|
requestContextTools,
|
|
764
782
|
onConversationCheckpoint,
|
|
765
783
|
).catch(error => {
|
|
@@ -873,6 +891,28 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
873
891
|
flushOpenToolCalls(output, stream, openBlockState);
|
|
874
892
|
}
|
|
875
893
|
const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
|
|
894
|
+
// #8345: a server-side per-conversation rejection surfaces as a bare
|
|
895
|
+
// resource_exhausted with zero tokens — the conversation is poisoned,
|
|
896
|
+
// not the account (sibling conversations keep working). Rotate the
|
|
897
|
+
// wire id once and migrate the cached state so the next attempt (the
|
|
898
|
+
// caller's retry loop) starts a fresh conversation, exactly like
|
|
899
|
+
// /fork. Only the first failure rotates; repeated failures keep the
|
|
900
|
+
// rotated id so a genuine account-level exhaustion is not hidden.
|
|
901
|
+
if (
|
|
902
|
+
conversationId !== undefined &&
|
|
903
|
+
baseConversationId !== undefined &&
|
|
904
|
+
usageState !== undefined &&
|
|
905
|
+
!usageState.sawTokenDelta &&
|
|
906
|
+
RESOURCE_EXHAUSTED_PATTERN.test(result.message) &&
|
|
907
|
+
!rotatedConversationIds.has(baseConversationId)
|
|
908
|
+
) {
|
|
909
|
+
const rotated = crypto.randomUUID();
|
|
910
|
+
rotatedConversationIds.set(baseConversationId, rotated);
|
|
911
|
+
const state = conversationStateCache.get(conversationId);
|
|
912
|
+
if (state) conversationStateCache.set(rotated, state);
|
|
913
|
+
const blobs = conversationBlobStores.get(conversationId);
|
|
914
|
+
if (blobs) conversationBlobStores.set(rotated, blobs);
|
|
915
|
+
}
|
|
876
916
|
output.stopReason = result.stopReason;
|
|
877
917
|
output.errorStatus = result.status;
|
|
878
918
|
output.errorId = result.id;
|