@oh-my-pi/pi-ai 18.0.3 → 18.0.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.4] - 2026-08-24
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Cursor tool calls through OpenAI-compatible authentication gateways losing arguments when complete argument maps are sent without streaming deltas ([#9479](https://github.com/can1357/oh-my-pi/issues/9479)).
10
+ - Fixed Cursor plan entitlement refusals repeatedly selecting ineligible accounts by scoping credential blocks to the requested model during rotation ([#9488](https://github.com/can1357/oh-my-pi/issues/9488)).
11
+ - Improved HTTP 413 error classification to accurately distinguish between payload/media size limits and token context window overflows, preventing inappropriate token compaction attempts and routing to correct recovery/fallback strategies ([#9235](https://github.com/can1357/oh-my-pi/issues/9235)).
12
+ - Fixed Cursor conversation rotation after aborts or mid-turn restarts to properly replay the last user message on a fresh conversation.
13
+
5
14
  ## [18.0.3] - 2026-08-23
6
15
 
7
16
  ### Fixed
@@ -1151,8 +1151,8 @@ export declare class AuthStorage {
1151
1151
  * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
1152
1152
  * (temporary block via its own backoff — default plus server usage-report
1153
1153
  * reset; sticky left intact so the next resolve re-ranks around the block).
1154
- * - exact Codex model-entitlement denial temporarily block only that
1155
- * requested model after provider/model identity matches, then rotate.
1154
+ * - exact model-entitlement denial (Codex ChatGPT account or Cursor plan) →
1155
+ * temporarily block only that requested model, then rotate.
1156
1156
  * - other account-scoped policy denial → temporarily block that account
1157
1157
  * without marking its credential suspect, then rotate through siblings.
1158
1158
  * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
@@ -23,6 +23,8 @@ export declare const Flag: {
23
23
  readonly FastModeUnsupported: 536870912;
24
24
  /** OAuth refresh failed definitively — the stored grant is dead, re-login required. */
25
25
  readonly OAuthExpiry: 1073741824;
26
+ /** HTTP 413 byte/media rejection — token compaction cannot shrink bytes or media budgets (#9235). */
27
+ readonly PayloadRejected: 2147483648;
26
28
  };
27
29
  export type Flag = (typeof Flag)[keyof typeof Flag];
28
30
  export declare const STREAM_READ_ERROR_PATTERN: RegExp;
@@ -65,6 +67,8 @@ export declare function isAccountPolicyError(error: unknown, api?: Api): boolean
65
67
  export declare function codexChatGPTAccountPolicyModel(error: unknown, depth?: number): string | undefined;
66
68
  /** Whether the exact Codex entitlement denial applies to this provider and requested model. */
67
69
  export declare function isCodexChatGPTAccountPolicyError(error: unknown, provider: string, modelId: string | undefined): boolean;
70
+ /** Whether Cursor returned a non-retryable plan entitlement denial for this account. */
71
+ export declare function isCursorPlanAccountPolicyError(error: unknown, provider: string, depth?: number): boolean;
68
72
  /**
69
73
  * Strict-tool rejection: grammar too large, schema too complex, or structured
70
74
  * outputs unsupported by the model/endpoint.
@@ -93,7 +97,16 @@ export declare function classifyMessage(message: {
93
97
  errorStatus?: number;
94
98
  }): number;
95
99
  export declare function attach<E extends object>(error: E, id: number): E;
100
+ /** Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235). */
101
+ export declare function isUsageBackedContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
96
102
  export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
103
+ /** HTTP 413 byte/media rejection (#9235); may co-occur with {@link isContextOverflow} for bare `413 (no body)`.
104
+ * Callers with local headroom should skip compaction when this returns true. */
105
+ export declare function isPayloadRejection(message: AssistantMessage): boolean;
106
+ /** Dual-flagged 413 (PayloadRejected + ContextOverflow) with no provider-reported token excess (#9235).
107
+ * The co-flag means a different provider's larger byte/media budget may accept the request.
108
+ * Usage-backed overflows are authoritative window excesses and never ambiguous. */
109
+ export declare function isTextAmbiguousContextOverflow(errorId: number, message: AssistantMessage | undefined, contextWindow?: number): boolean;
97
110
  export declare function stringify(id: number | undefined): string;
98
111
  /**
99
112
  * Transient stream corruption where the response was truncated mid-JSON.
@@ -19,6 +19,7 @@ interface CursorRequestState {
19
19
  conversationId: string;
20
20
  blobStore: Map<string, Uint8Array>;
21
21
  conversationState?: ConversationStateStructure;
22
+ rotatedFresh?: boolean;
22
23
  }
23
24
  interface CursorGrpcRequest {
24
25
  requestBytes: Uint8Array;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "18.0.3",
4
+ "version": "18.0.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,10 +37,10 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/omptype": "18.0.3",
41
- "@oh-my-pi/pi-catalog": "18.0.3",
42
- "@oh-my-pi/pi-utils": "18.0.3",
43
- "@oh-my-pi/pi-wire": "18.0.3"
40
+ "@oh-my-pi/omptype": "18.0.4",
41
+ "@oh-my-pi/pi-catalog": "18.0.4",
42
+ "@oh-my-pi/pi-utils": "18.0.4",
43
+ "@oh-my-pi/pi-wire": "18.0.4"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/bun": "^1.3.14"
@@ -1028,9 +1028,13 @@ function resolveOpenAICodexPlanRequirement(provider: string, modelId: string | u
1028
1028
  }
1029
1029
 
1030
1030
  const MODEL_ACCOUNT_POLICY_BLOCK_SCOPE_PREFIX = "model-policy:";
1031
+ const MODEL_ACCOUNT_POLICY_PROVIDERS: Readonly<Record<string, true>> = {
1032
+ "openai-codex": true,
1033
+ cursor: true,
1034
+ };
1031
1035
 
1032
1036
  function modelAccountPolicyBlockScope(provider: string, modelId: string | undefined): string | undefined {
1033
- if (provider !== "openai-codex" || typeof modelId !== "string") return undefined;
1037
+ if (!Object.hasOwn(MODEL_ACCOUNT_POLICY_PROVIDERS, provider) || typeof modelId !== "string") return undefined;
1034
1038
  const separator = modelId.lastIndexOf("/");
1035
1039
  const bareModelId = (separator === -1 ? modelId : modelId.slice(separator + 1)).trim().toLowerCase();
1036
1040
  if (!bareModelId || bareModelId.includes("\0")) return undefined;
@@ -6393,8 +6397,8 @@ export class AuthStorage {
6393
6397
  * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
6394
6398
  * (temporary block via its own backoff — default plus server usage-report
6395
6399
  * reset; sticky left intact so the next resolve re-ranks around the block).
6396
- * - exact Codex model-entitlement denial temporarily block only that
6397
- * requested model after provider/model identity matches, then rotate.
6400
+ * - exact model-entitlement denial (Codex ChatGPT account or Cursor plan) →
6401
+ * temporarily block only that requested model, then rotate.
6398
6402
  * - other account-scoped policy denial → temporarily block that account
6399
6403
  * without marking its credential suspect, then rotate through siblings.
6400
6404
  * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
@@ -6411,7 +6415,9 @@ export class AuthStorage {
6411
6415
  const error = options?.error;
6412
6416
  const status = AIError.status(error);
6413
6417
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
6414
- if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
6418
+ const exactCursorModelPolicy = AIError.isCursorPlanAccountPolicyError(error, provider);
6419
+ const accountPolicy = exactCursorModelPolicy || AIError.isAccountPolicyError(error);
6420
+ if (!accountPolicy && (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message))) {
6415
6421
  // Thread the provider-specified reset window (e.g. Devin "Your limit
6416
6422
  // will reset in 13 minutes") into the block duration so the credential
6417
6423
  // is not reselected and hammered while the cap remains active.
@@ -6436,15 +6442,16 @@ export class AuthStorage {
6436
6442
  const deniedModel = AIError.codexChatGPTAccountPolicyModel(error);
6437
6443
  const exactCodexModelPolicy =
6438
6444
  deniedModel !== undefined && AIError.isCodexChatGPTAccountPolicyError(error, provider, options?.modelId);
6445
+ const exactModelPolicy = exactCodexModelPolicy || exactCursorModelPolicy;
6439
6446
  // The exact sentence is provider-controlled input. A non-Codex provider,
6440
6447
  // absent request model, or mismatched model must not turn it into either a
6441
6448
  // global block or a hard-auth invalidation.
6442
6449
  if (deniedModel !== undefined && !exactCodexModelPolicy) return false;
6443
- if (exactCodexModelPolicy || AIError.isAccountPolicyError(error)) {
6444
- const modelPolicyScope = exactCodexModelPolicy
6450
+ if (exactModelPolicy || accountPolicy) {
6451
+ const modelPolicyScope = exactModelPolicy
6445
6452
  ? modelAccountPolicyBlockScope(provider, options?.modelId)
6446
6453
  : undefined;
6447
- if (exactCodexModelPolicy && modelPolicyScope === undefined) return false;
6454
+ if (exactModelPolicy && modelPolicyScope === undefined) return false;
6448
6455
  const routing = this.#credentialBlockRouting(
6449
6456
  provider,
6450
6457
  sessionCredential.type,
@@ -40,6 +40,8 @@ export const Flag = {
40
40
  FastModeUnsupported: 0x2000_0000,
41
41
  /** OAuth refresh failed definitively — the stored grant is dead, re-login required. */
42
42
  OAuthExpiry: 0x4000_0000,
43
+ /** HTTP 413 byte/media rejection — token compaction cannot shrink bytes or media budgets (#9235). */
44
+ PayloadRejected: 0x8000_0000,
43
45
  } as const;
44
46
 
45
47
  export type Flag = (typeof Flag)[keyof typeof Flag];
@@ -57,6 +59,7 @@ const KIND_MASK =
57
59
  Flag.AccountPolicy |
58
60
  Flag.ContextOverflow |
59
61
  Flag.AuthFailed |
62
+ Flag.PayloadRejected |
60
63
  Flag.SilentAbort |
61
64
  Flag.UserInterrupt |
62
65
  Flag.Abort |
@@ -72,7 +75,7 @@ const RETRIABLE_KINDS =
72
75
  Flag.ProviderFinishError |
73
76
  Flag.EmptyResponse;
74
77
 
75
- const OVERFLOW_PATTERNS = [
78
+ const CONTEXT_OVERFLOW_EVIDENCE_PATTERNS = [
76
79
  /prompt is too long/i, // Anthropic
77
80
  /input is too long for requested model/i, // Amazon Bedrock
78
81
  /exceeds the context window/i, // OpenAI (Completions & Responses API)
@@ -80,7 +83,6 @@ const OVERFLOW_PATTERNS = [
80
83
  /maximum prompt length is \d+/i, // xAI (Grok)
81
84
  /reduce the length of the messages/i, // Groq
82
85
  /maximum context length is \d+ tokens/i, // OpenRouter (all backends)
83
- /exceeds the limit of \d+/i, // GitHub Copilot
84
86
  /exceeds the available context size/i, // llama.cpp server
85
87
  /requested tokens?.*exceed.*context (window|length|size)/i, // llama.cpp / OpenAI-compatible local servers
86
88
  /context (window|length|size).*(exceeded|overflow|too small)/i, // Generic local server variants
@@ -92,16 +94,62 @@ const OVERFLOW_PATTERNS = [
92
94
  /context[_ ]length[_ ]exceeded/i, // Generic fallback
93
95
  /too many tokens/i, // Generic fallback
94
96
  /token limit exceeded/i, // Generic fallback
95
- /request_too_large/i, // Anthropic 413 (request body too large)
96
- /request exceeds the maximum size/i, // Anthropic 413 variant
97
- /payload too large/i, // Generic HTTP 413 variant
98
- /entity too large/i, // Generic HTTP 413 variant
99
- /\b413\b.*\b(request|payload|entity)\b.*\btoo large\b/i, // "413 Request Entity Too Large" variants
97
+ /request_too_large[^\n]*\btokens?\b/i,
98
+ /\btokens?\b[^\n]*request_too_large/i,
100
99
  /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
101
100
  /prompt filled the context window/i, // Ollama OpenAI-compatible empty length completion
102
- ];
101
+ /exceeds the limit of \d+ tokens?\b/i,
102
+ ] as const;
103
+ // Numeric limit pattern — also matches media budgets, so must never veto a payload flag (#9235).
104
+ const GENERIC_LIMIT_OVERFLOW_PATTERN = /exceeds the limit of \d+/i;
105
+ const OVERFLOW_PATTERNS = [...CONTEXT_OVERFLOW_EVIDENCE_PATTERNS, GENERIC_LIMIT_OVERFLOW_PATTERN];
106
+
107
+ /** Token-context evidence only — excludes GENERIC_LIMIT_OVERFLOW_PATTERN which media budgets also match (#9235). */
108
+ function hasTokenContextOverflowEvidence(text: string): boolean {
109
+ return CONTEXT_OVERFLOW_EVIDENCE_PATTERNS.some(p => p.test(text));
110
+ }
111
+
112
+ /** Checks every cause-chain link; a wrapper whose nested 413 names the token budget stays overflow-only (#9235). */
113
+ function hasCauseTokenContextOverflowEvidence(error: unknown): boolean {
114
+ const seen = new Set<object>();
115
+ let link: unknown = error;
116
+ while (link !== undefined && link !== null) {
117
+ if (typeof link !== "object") {
118
+ if (typeof link === "string" && hasTokenContextOverflowEvidence(link)) return true;
119
+ break;
120
+ }
121
+ if (seen.has(link)) break;
122
+ seen.add(link);
123
+ if ("message" in link) {
124
+ const message: unknown = link.message;
125
+ if (typeof message === "string" && hasTokenContextOverflowEvidence(message)) return true;
126
+ }
127
+ if ("cause" in link) {
128
+ link = link.cause;
129
+ continue;
130
+ }
131
+ break;
132
+ }
133
+ return false;
134
+ }
103
135
 
104
136
  const OVERFLOW_NO_BODY_PATTERN = /\b4(00|13)\s*(status code)?\s*\(no body\)/i;
137
+ // Bare `413 (no body)` deliberately dual-flagged; maintenance arbitrates against local headroom (#9235).
138
+ const PAYLOAD_REJECTION_PATTERNS = [
139
+ /\b413\s*(?:status code\s*)?\(no body\)/i,
140
+ /\b413\b[^.\n]{0,120}\b(?:request|payload|entity|body)\b[^.\n]{0,60}\b(?:exceed|too large|limit)/i,
141
+ /request_too_large/i,
142
+ /(?:payload|entity) too large/i,
143
+ /request exceeds the maximum (?:size|number of bytes)/i,
144
+ ] as const;
145
+
146
+ function matchesPayloadRejectionText(text: string): boolean {
147
+ if (!PAYLOAD_REJECTION_PATTERNS.some(p => p.test(text))) return false;
148
+ // Token-context evidence vetoes; only token wording counts — generic numeric limits also match media budgets.
149
+ if (hasTokenContextOverflowEvidence(text)) return false;
150
+ return true;
151
+ }
152
+
105
153
  const TIMEOUT_PATTERN = /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i;
106
154
  const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
107
155
  const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
@@ -118,6 +166,12 @@ const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
118
166
  const CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN =
119
167
  /\bThe ['"]([^'"\r\n]+)['"] model is not supported when using Codex with a ChatGPT account\./i;
120
168
  const CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH = 256;
169
+ const CURSOR_PLAN_POLICY_MARKER_PATTERN = /\bERROR_RATE_LIMITED_CHANGEABLE\b/i;
170
+ const CURSOR_PLAN_POLICY_PATTERN = /\bNamed models unavailable\b|\bModel unavailable on\b|\bFree plans can only use\b/i;
171
+
172
+ function isCursorPlanPolicyText(text: string): boolean {
173
+ return CURSOR_PLAN_POLICY_MARKER_PATTERN.test(text) && CURSOR_PLAN_POLICY_PATTERN.test(text);
174
+ }
121
175
 
122
176
  function normalizeCodexChatGPTAccountPolicyModel(modelId: string | undefined): string | undefined {
123
177
  if (typeof modelId !== "string") return undefined;
@@ -249,6 +303,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
249
303
  [Flag.ContentBlocked, "content-blocked"],
250
304
  [Flag.AccountPolicy, "account-policy"],
251
305
  [Flag.ContextOverflow, "context-overflow"],
306
+ [Flag.PayloadRejected, "payload-rejected"],
252
307
  [Flag.AuthFailed, "auth-failed"],
253
308
  [Flag.SilentAbort, "silent-abort"],
254
309
  [Flag.UserInterrupt, "user-interrupt"],
@@ -275,6 +330,7 @@ export function is(id: number | undefined, flag: Flag): boolean {
275
330
 
276
331
  export function retriable(id: number | undefined, opts?: { replayUnsafe?: boolean }): boolean {
277
332
  if (is(id, Flag.ContentBlocked)) return false;
333
+ if (is(id, Flag.PayloadRejected)) return false;
278
334
  if (opts?.replayUnsafe) return false;
279
335
  if (is(id, Flag.MalformedFunctionCall)) return true;
280
336
  return ((id ?? 0) & RETRIABLE_KINDS) !== 0;
@@ -386,6 +442,7 @@ function matchesOverflowText(text: string): boolean {
386
442
  function classifyText(
387
443
  errorMessage: string | undefined,
388
444
  errorStatus: number | undefined,
445
+ priorTokenOverflowEvidence = false,
389
446
  api?: Api,
390
447
  provider?: string,
391
448
  modelId?: string,
@@ -393,13 +450,15 @@ function classifyText(
393
450
  let kinds = 0;
394
451
  if (errorMessage) {
395
452
  if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
453
+ if (matchesPayloadRejectionText(errorMessage)) kinds |= Flag.PayloadRejected;
396
454
  if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
397
455
  if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
398
456
  if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
399
457
  if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
400
458
  if (
401
459
  ACCOUNT_POLICY_PATTERN.test(errorMessage) ||
402
- isCodexChatGPTAccountPolicyText(errorMessage, provider, modelId)
460
+ isCodexChatGPTAccountPolicyText(errorMessage, provider, modelId) ||
461
+ (provider === "cursor" && isCursorPlanPolicyText(errorMessage))
403
462
  ) {
404
463
  kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
405
464
  }
@@ -450,6 +509,15 @@ function classifyText(
450
509
  if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar;
451
510
  if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported;
452
511
  }
512
+ // Status-only 413: infer PayloadRejected unless prior classification carries token-context evidence (#9235).
513
+ const statusEvidence = errorStatus ?? (errorMessage ? status({ message: errorMessage }) : undefined);
514
+ if (
515
+ statusEvidence === 413 &&
516
+ !priorTokenOverflowEvidence &&
517
+ !(errorMessage && hasTokenContextOverflowEvidence(errorMessage))
518
+ ) {
519
+ kinds |= Flag.PayloadRejected;
520
+ }
453
521
  if (kinds !== 0) return create(kinds);
454
522
  const fallbackStatus = errorStatus ?? (errorMessage ? status({ message: errorMessage }) : undefined);
455
523
  if (fallbackStatus === 401 || fallbackStatus === 403) return create(Flag.AuthFailed);
@@ -459,6 +527,7 @@ function classifyText(
459
527
  export function classify(error: unknown, api?: Api): number {
460
528
  let kinds = 0;
461
529
  const seen = new Set<object>();
530
+ const causeTokenEvidence = hasCauseTokenContextOverflowEvidence(error);
462
531
  let link: unknown = error;
463
532
  while (link !== undefined && link !== null) {
464
533
  if (typeof link === "object") {
@@ -532,7 +601,7 @@ export function classify(error: unknown, api?: Api): number {
532
601
  linkMessage = (link as { message: string }).message;
533
602
  }
534
603
 
535
- const textId = classifyText(linkMessage, status(link), api);
604
+ const textId = classifyText(linkMessage, status(link), causeTokenEvidence, api);
536
605
  kinds |= textId & KIND_MASK;
537
606
 
538
607
  link = typeof link === "object" && "cause" in link ? (link as { cause: unknown }).cause : undefined;
@@ -586,6 +655,22 @@ export function isCodexChatGPTAccountPolicyError(
586
655
  return provider === "openai-codex" && deniedIdentity !== undefined && deniedIdentity === requestedIdentity;
587
656
  }
588
657
 
658
+ /** Whether Cursor returned a non-retryable plan entitlement denial for this account. */
659
+ export function isCursorPlanAccountPolicyError(error: unknown, provider: string, depth = 0): boolean {
660
+ if (provider !== "cursor" || depth > 6) return false;
661
+ if (typeof error === "string") return isCursorPlanPolicyText(error);
662
+ if (!error || typeof error !== "object") return false;
663
+ if (
664
+ "errorMessage" in error &&
665
+ typeof error.errorMessage === "string" &&
666
+ isCursorPlanPolicyText(error.errorMessage)
667
+ ) {
668
+ return true;
669
+ }
670
+ if ("message" in error && typeof error.message === "string" && isCursorPlanPolicyText(error.message)) return true;
671
+ return "cause" in error && isCursorPlanAccountPolicyError(error.cause, provider, depth + 1);
672
+ }
673
+
589
674
  /**
590
675
  * Strict-tool rejection: grammar too large, schema too complex, or structured
591
676
  * outputs unsupported by the model/endpoint.
@@ -648,10 +733,28 @@ export function classifyMessage(message: {
648
733
  }): number {
649
734
  const existingId = message.errorId;
650
735
  const currentStatus = message.errorStatus ?? statusFromId(existingId);
736
+ const existingOverflowOnly =
737
+ existingId !== undefined && is(existingId, Flag.ContextOverflow) && !is(existingId, Flag.PayloadRejected);
651
738
  const classificationMessage = message.errorClassificationMessage ?? message.errorMessage;
652
- const textId = classifyText(classificationMessage, currentStatus, message.api, message.provider, message.model);
739
+ const textId = classifyText(
740
+ classificationMessage,
741
+ currentStatus,
742
+ existingOverflowOnly,
743
+ message.api,
744
+ message.provider,
745
+ message.model,
746
+ );
653
747
 
654
748
  let kinds = ((existingId ?? 0) | textId) & KIND_MASK;
749
+ // Two-phase finalization: drop stale status-inferred payload bit when final text proves token overflow (#9235).
750
+ if (
751
+ currentStatus === 413 &&
752
+ classificationMessage &&
753
+ hasTokenContextOverflowEvidence(classificationMessage) &&
754
+ !(textId & Flag.PayloadRejected)
755
+ ) {
756
+ kinds &= ~Flag.PayloadRejected;
757
+ }
655
758
  if (classificationMessage && LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(classificationMessage)) {
656
759
  // Deterministic local-model tool-call JSON parse failure: HTTP 500 is misleading
657
760
  // because the same prompt reproduces the same malformed output, so the agent-level
@@ -669,15 +772,43 @@ export function attach<E extends object>(error: E, id: number): E {
669
772
  return error;
670
773
  }
671
774
 
775
+ /** Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235). */
776
+ export function isUsageBackedContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
777
+ if (!contextWindow) return false;
778
+ const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
779
+ return inputTokens > contextWindow;
780
+ }
781
+
672
782
  export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
673
783
  if (is(message.errorId, Flag.ContextOverflow)) return true;
674
- if (contextWindow) {
675
- const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
676
- if (inputTokens > contextWindow) return true;
677
- }
784
+ if (isUsageBackedContextOverflow(message, contextWindow)) return true;
678
785
  return message.stopReason === "error" && !!message.errorMessage && matchesOverflowText(message.errorMessage);
679
786
  }
680
787
 
788
+ /** HTTP 413 byte/media rejection (#9235); may co-occur with {@link isContextOverflow} for bare `413 (no body)`.
789
+ * Callers with local headroom should skip compaction when this returns true. */
790
+ export function isPayloadRejection(message: AssistantMessage): boolean {
791
+ if (is(message.errorId, Flag.PayloadRejected)) return true;
792
+ const { errorMessage } = message;
793
+ if (message.stopReason !== "error" || !errorMessage) return false;
794
+ return matchesPayloadRejectionText(errorMessage);
795
+ }
796
+
797
+ /** Dual-flagged 413 (PayloadRejected + ContextOverflow) with no provider-reported token excess (#9235).
798
+ * The co-flag means a different provider's larger byte/media budget may accept the request.
799
+ * Usage-backed overflows are authoritative window excesses and never ambiguous. */
800
+ export function isTextAmbiguousContextOverflow(
801
+ errorId: number,
802
+ message: AssistantMessage | undefined,
803
+ contextWindow?: number,
804
+ ): boolean {
805
+ const overflowFlagged =
806
+ is(errorId, Flag.ContextOverflow) || (message !== undefined && isContextOverflow(message, contextWindow));
807
+ if (!overflowFlagged) return false;
808
+ if (!is(errorId, Flag.PayloadRejected)) return false;
809
+ return !(message !== undefined && isUsageBackedContextOverflow(message, contextWindow));
810
+ }
811
+
681
812
  export function stringify(id: number | undefined): string {
682
813
  if (!id) return "none";
683
814
  if (!isClassified(id)) return `status:${id}`;
@@ -106,6 +106,7 @@ function isRetryableOneshotFailure(errorId: number, errorStatus: number | undefi
106
106
  // identically on every attempt. Retrying burns the caller's deadline instead
107
107
  // of reaching the fallback that can actually shrink the input.
108
108
  if (AIError.is(errorId, AIError.Flag.ContextOverflow)) return false;
109
+ if (AIError.is(errorId, AIError.Flag.PayloadRejected)) return false;
109
110
  return (
110
111
  AIError.isTransientStatus(errorStatus) ||
111
112
  AIError.is(errorId, AIError.Flag.Transient) ||
@@ -327,13 +327,15 @@ const warnedCursorKimiK3ReplayMessages = new Set<string>();
327
327
  /**
328
328
  * Base conversation id → rotated wire id (#8345). Cursor's backend can pin a
329
329
  * per-conversation rejection (bare `resource_exhausted`, zero tokens) to one
330
- * conversationId forever; the session is then unusable until /fork mints a
331
- * new id. On the first such failure the id is rotated once and the cached
332
- * state migrates, so the retry loop's next attempt starts a fresh
333
- * conversation the same recovery /fork performs. Keyed by the base id the
334
- * caller derived, so a failed rotation is never repeated.
330
+ * conversationId forever. On such a failure the id is rotated and the next
331
+ * attempt rebuilds a fresh conversation from `context` (no cached-state
332
+ * migration). A failed rotation is not repeated, so real account exhaustion
333
+ * is not hidden. After the rotated id completes a turn, a later poison of
334
+ * that id is allowed to rotate again.
335
335
  */
336
336
  const rotatedConversationIds = new Map<string, string>();
337
+ const successfulRotatedConversationIds = new Set<string>();
338
+ const freshRotatedConversationIds = new Set<string>();
337
339
 
338
340
  export interface CursorOptions extends StreamOptions {
339
341
  customSystemPrompt?: string;
@@ -355,6 +357,7 @@ interface CursorRequestState {
355
357
  conversationId: string;
356
358
  blobStore: Map<string, Uint8Array>;
357
359
  conversationState?: ConversationStateStructure;
360
+ rotatedFresh?: boolean;
358
361
  }
359
362
 
360
363
  interface CursorGrpcRequest {
@@ -680,9 +683,10 @@ function streamCursorWithWireMode(
680
683
 
681
684
  baseConversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
682
685
  conversationId = rotatedConversationIds.get(baseConversationId) ?? baseConversationId;
686
+ const rotatedFresh = freshRotatedConversationIds.has(conversationId);
683
687
  const blobStore = conversationBlobStores.get(conversationId) ?? new Map<string, Uint8Array>();
684
688
  conversationBlobStores.set(conversationId, blobStore);
685
- const cachedState = conversationStateCache.get(conversationId);
689
+ const cachedState = rotatedFresh ? undefined : conversationStateCache.get(conversationId);
686
690
  const builtRequest = await buildGrpcRequestForWireMode(
687
691
  model,
688
692
  context,
@@ -691,6 +695,7 @@ function streamCursorWithWireMode(
691
695
  conversationId,
692
696
  blobStore,
693
697
  conversationState: cachedState,
698
+ rotatedFresh,
694
699
  },
695
700
  wireMode,
696
701
  );
@@ -929,6 +934,10 @@ function streamCursorWithWireMode(
929
934
  h2Request.write(frameConnectMessage(requestBytes));
930
935
  heartbeatTimer = setInterval(sendHeartbeat, 5000);
931
936
  await h2Completion.promise;
937
+ if (conversationId && baseConversationId && conversationId !== baseConversationId) {
938
+ successfulRotatedConversationIds.add(conversationId);
939
+ freshRotatedConversationIds.delete(conversationId);
940
+ }
932
941
  // The transport is done, but a handler decoded from the last chunk may
933
942
  // still be running: exec handlers and `onToolResult` transformers are
934
943
  // async. Pushing `done` now would let the Agent drain its Cursor result
@@ -1013,25 +1022,31 @@ function streamCursorWithWireMode(
1013
1022
  const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
1014
1023
  // #8345: a server-side per-conversation rejection surfaces as a bare
1015
1024
  // resource_exhausted with zero tokens — the conversation is poisoned,
1016
- // not the account (sibling conversations keep working). Rotate the
1017
- // wire id once and migrate the cached state so the next attempt (the
1018
- // caller's retry loop) starts a fresh conversation, exactly like
1019
- // /fork. Only the first failure rotates; repeated failures keep the
1020
- // rotated id so a genuine account-level exhaustion is not hidden.
1025
+ // not the account. Rotate the wire id and rebuild from `context` on
1026
+ // the next attempt (the caller's retry loop). Do not migrate cached
1027
+ // side-state: pendingToolCalls from a mid-turn checkpoint re-poison
1028
+ // the new id. One rotation per failure streak; a new rotation is
1029
+ // allowed only after the current rotated id completed a turn.
1030
+ const currentRotated =
1031
+ baseConversationId === undefined ? undefined : rotatedConversationIds.get(baseConversationId);
1032
+ const canRotate = currentRotated === undefined || successfulRotatedConversationIds.has(currentRotated);
1021
1033
  if (
1022
1034
  conversationId !== undefined &&
1023
1035
  baseConversationId !== undefined &&
1024
1036
  usageState !== undefined &&
1025
1037
  !usageState.sawTokenDelta &&
1026
1038
  RESOURCE_EXHAUSTED_PATTERN.test(result.message) &&
1027
- !rotatedConversationIds.has(baseConversationId)
1039
+ canRotate
1028
1040
  ) {
1029
1041
  const rotated = crypto.randomUUID();
1042
+ if (currentRotated) successfulRotatedConversationIds.delete(currentRotated);
1030
1043
  rotatedConversationIds.set(baseConversationId, rotated);
1031
- const state = conversationStateCache.get(conversationId);
1032
- if (state) conversationStateCache.set(rotated, state);
1033
- const blobs = conversationBlobStores.get(conversationId);
1034
- if (blobs) conversationBlobStores.set(rotated, blobs);
1044
+ freshRotatedConversationIds.add(rotated);
1045
+ logger.debug("cursor conversation rotated", {
1046
+ base: baseConversationId,
1047
+ from: conversationId,
1048
+ to: rotated,
1049
+ });
1035
1050
  }
1036
1051
  output.stopReason = result.stopReason;
1037
1052
  output.errorStatus = result.status;
@@ -4260,9 +4275,8 @@ export function processInteractionUpdate(
4260
4275
  // what the exec channel pairs its result under. Diverging here would
4261
4276
  // name the block one thing and its result another.
4262
4277
  name: args.toolName || args.name || "",
4263
- arguments: {},
4278
+ arguments: decodeMcpArgsMap(args.args) ?? {},
4264
4279
  [kStreamingBlockIndex]: output.content.length,
4265
- [kStreamingPartialJson]: "",
4266
4280
  [kStreamingBlockKind]: "mcp",
4267
4281
  [kStreamingEnvelopeId]: update.message.value.callId || undefined,
4268
4282
  };
@@ -4385,7 +4399,7 @@ export function processInteractionUpdate(
4385
4399
  // Authoritative full parse of the accumulated argument buffer; the delta
4386
4400
  // path throttles mid-stream parses, so `arguments` may lag the buffer.
4387
4401
  const partial = settled[kStreamingPartialJson];
4388
- if (partial !== undefined) {
4402
+ if (partial) {
4389
4403
  settled.arguments = parseStreamingJson(partial);
4390
4404
  }
4391
4405
  const decodedArgs = decodeMcpArgsMap(selectMcpCall(toolCall)?.args?.args);
@@ -5210,10 +5224,18 @@ async function buildGrpcRequestForWireMode(
5210
5224
  storeCursorBlob(blobStore, new TextEncoder().encode(json)),
5211
5225
  );
5212
5226
 
5213
- const activeUserMessageIndex = context.messages.length - 1;
5227
+ const lastUserMessageIndex = findLastUserMessageIndex(context.messages);
5228
+ let activeUserMessageIndex = context.messages.length - 1;
5214
5229
  const activeMessage = context.messages[activeUserMessageIndex];
5215
- const activeUserMessage =
5230
+ let activeUserMessage =
5216
5231
  activeMessage?.role === "user" || activeMessage?.role === "developer" ? activeMessage : undefined;
5232
+ if (state.rotatedFresh && !activeUserMessage && lastUserMessageIndex >= 0) {
5233
+ activeUserMessageIndex = lastUserMessageIndex;
5234
+ const lastUser = context.messages[lastUserMessageIndex];
5235
+ if (lastUser.role === "user" || lastUser.role === "developer") {
5236
+ activeUserMessage = lastUser;
5237
+ }
5238
+ }
5217
5239
  let userContent: string | (TextContent | ImageContent)[] | undefined;
5218
5240
  let userText = "";
5219
5241
  let hasUserImages = false;
@@ -582,9 +582,9 @@ export function encodeStream(
582
582
  async start(controller) {
583
583
  // contentIndex (from pi-ai events) -> tool_calls index on the wire.
584
584
  const toolIndexByContentIndex = new Map<number, number>();
585
- // wire index -> id/name emitted on the start chunk, to detect late-arriving
586
- // upstream id/name that needs a corrective chunk before the finish.
587
- const sentToolMeta = new Map<number, { id: string; name: string }>();
585
+ // wire index -> metadata emitted so far, to detect values that need a
586
+ // concatenation-safe corrective chunk before the finish.
587
+ const sentToolMeta = new Map<number, { id: string; name: string; hasArgumentBytes: boolean }>();
588
588
  let nextToolIndex = 0;
589
589
  let hasToolCalls = false;
590
590
  let finishReason: string = "stop";
@@ -620,7 +620,7 @@ export function encodeStream(
620
620
  toolIndexByContentIndex.set(event.contentIndex, idx);
621
621
  const partial = event.partial.content[event.contentIndex];
622
622
  const call = partial && partial.type === "toolCall" ? partial : undefined;
623
- sentToolMeta.set(idx, { id: call?.id ?? "", name: call?.name ?? "" });
623
+ sentToolMeta.set(idx, { id: call?.id ?? "", name: call?.name ?? "", hasArgumentBytes: false });
624
624
  writeSse(
625
625
  controller,
626
626
  baseChunk(
@@ -643,6 +643,8 @@ export function encodeStream(
643
643
  case "toolcall_delta": {
644
644
  const idx = toolIndexByContentIndex.get(event.contentIndex);
645
645
  if (idx === undefined) break;
646
+ const sent = sentToolMeta.get(idx);
647
+ if (sent && event.delta.length > 0) sent.hasArgumentBytes = true;
646
648
  writeSse(
647
649
  controller,
648
650
  baseChunk({ tool_calls: [{ index: idx, function: { arguments: event.delta } }] }, null),
@@ -655,14 +657,17 @@ export function encodeStream(
655
657
  if (idx === undefined) break;
656
658
  const sent = sentToolMeta.get(idx);
657
659
  if (sent === undefined) break;
658
- // Upstream completions providers can receive the real id/name in a
659
- // later chunk than toolcall_start. Emit a corrective chunk only when
660
- // the streamed value was empty: accumulating clients concatenate
661
- // string fields, so "" + value is the only safe correction.
660
+ // Upstream providers can settle id, name, or arguments after the
661
+ // start chunk. Emit corrections only for fields whose streamed
662
+ // value was empty: accumulating clients concatenate each field,
663
+ // so "" + value is the only safe correction.
662
664
  const correctId = sent.id === "" && event.toolCall.id !== "" ? event.toolCall.id : undefined;
663
665
  const correctName =
664
666
  sent.name === "" && event.toolCall.name !== "" ? event.toolCall.name : undefined;
665
- if (correctId !== undefined || correctName !== undefined) {
667
+ const correctArguments = sent.hasArgumentBytes
668
+ ? undefined
669
+ : stringifyArgs(event.toolCall.arguments);
670
+ if (correctId !== undefined || correctName !== undefined || correctArguments !== undefined) {
666
671
  writeSse(
667
672
  controller,
668
673
  baseChunk(
@@ -671,7 +676,16 @@ export function encodeStream(
671
676
  {
672
677
  index: idx,
673
678
  ...(correctId !== undefined ? { id: correctId } : {}),
674
- ...(correctName !== undefined ? { function: { name: correctName } } : {}),
679
+ ...(correctName !== undefined || correctArguments !== undefined
680
+ ? {
681
+ function: {
682
+ ...(correctName !== undefined ? { name: correctName } : {}),
683
+ ...(correctArguments !== undefined
684
+ ? { arguments: correctArguments }
685
+ : {}),
686
+ },
687
+ }
688
+ : {}),
675
689
  },
676
690
  ],
677
691
  },
package/src/stream.ts CHANGED
@@ -7,9 +7,9 @@ import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic
7
7
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
8
8
  import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl, resolveVertexEndpointHost } from "@oh-my-pi/pi-catalog/hosts";
9
9
  import {
10
+ defaultSupportedEffort,
10
11
  mapEffortToAnthropicAdaptiveEffort,
11
12
  mapEffortToGoogleThinkingLevel,
12
- minimumSupportedEffort,
13
13
  requireSupportedEffort,
14
14
  resolveWireModelId,
15
15
  } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -1890,7 +1890,7 @@ function normalizeMandatoryReasoningOptions<TApi extends Api>(
1890
1890
  ) {
1891
1891
  return options;
1892
1892
  }
1893
- const floor = minimumSupportedEffort(model);
1893
+ const floor = defaultSupportedEffort(model);
1894
1894
  if (floor === undefined) return options;
1895
1895
  return { ...options, reasoning: floor, disableReasoning: undefined, forceReasoningOff: undefined };
1896
1896
  }