@oh-my-pi/pi-ai 18.4.0 → 18.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -0
  3. package/dist/types/auth/oauth.d.ts +2 -2
  4. package/dist/types/auth/refresh.d.ts +3 -3
  5. package/dist/types/auth/store.d.ts +4 -2
  6. package/dist/types/auth/types.d.ts +20 -3
  7. package/dist/types/auth/usage-cache.d.ts +9 -2
  8. package/dist/types/auth/usage.d.ts +6 -5
  9. package/dist/types/auth-broker/client.d.ts +2 -2
  10. package/dist/types/auth-broker/remote-store.d.ts +2 -2
  11. package/dist/types/error/flags.d.ts +9 -2
  12. package/dist/types/error/rate-limit.d.ts +3 -2
  13. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  14. package/dist/types/providers/cursor-pi-args.d.ts +13 -18
  15. package/dist/types/stream.d.ts +7 -0
  16. package/dist/types/usage/cursor.d.ts +3 -1
  17. package/dist/types/usage/zai.d.ts +2 -0
  18. package/dist/types/usage.d.ts +6 -0
  19. package/package.json +6 -6
  20. package/src/auth/cascade.ts +1 -0
  21. package/src/auth/oauth.ts +4 -3
  22. package/src/auth/refresh.ts +105 -16
  23. package/src/auth/resets.ts +6 -2
  24. package/src/auth/select.ts +11 -1
  25. package/src/auth/store.ts +4 -0
  26. package/src/auth/types.ts +22 -2
  27. package/src/auth/usage-cache.ts +12 -8
  28. package/src/auth/usage.ts +45 -19
  29. package/src/auth-broker/client.ts +8 -3
  30. package/src/auth-broker/remote-store.ts +4 -2
  31. package/src/auth-broker/server.ts +7 -1
  32. package/src/auth-gateway/dispatch.ts +1 -0
  33. package/src/auth-retry.ts +5 -1
  34. package/src/error/flags.ts +10 -3
  35. package/src/error/rate-limit.ts +23 -3
  36. package/src/providers/anthropic.ts +62 -53
  37. package/src/providers/cowork-fetch.ts +45 -3
  38. package/src/providers/cursor/exec-modern.ts +1 -1
  39. package/src/providers/cursor-pi-args.ts +13 -18
  40. package/src/providers/cursor.ts +179 -57
  41. package/src/providers/devin.ts +43 -13
  42. package/src/providers/openai-completions.ts +122 -30
  43. package/src/stream.ts +77 -16
  44. package/src/usage/claude.ts +5 -0
  45. package/src/usage/cursor.ts +43 -1
  46. package/src/usage/devin.ts +8 -7
  47. package/src/usage/registry.ts +2 -1
  48. package/src/usage/zai.ts +20 -0
  49. package/src/usage.ts +6 -0
@@ -110,6 +110,17 @@ export function isDashScopeTokenLimitText(errorMessage: string): boolean {
110
110
  );
111
111
  }
112
112
 
113
+ // Rolling per-minute token/request throttles (TPM/RPM). Providers report these
114
+ // with quota wording — "tpm exhausted (type=quota_exceeded_error)",
115
+ // "inference exceeds tpm/rpm limit", "RateLimitExceeded.EndpointTPMExceeded" —
116
+ // but the window self-heals within the minute, so they belong in the transient
117
+ // backoff lane, not the 30-minute credential-blocking quota lane (#13253).
118
+ // Deliberately subordinate to the account-scoped arms of
119
+ // {@link parseRateLimitReason}: a message that also carries a plan/spend/
120
+ // account-quota signal classifies there first and keeps its quota verdict.
121
+ const TPM_RPM_THROTTLE_PATTERN =
122
+ /\b(?:tpm|rpm)\b[^\n]{0,40}\b(?:exhaust\w*|exceed\w*|limit\w*|throttl\w*|reach\w*)\b|\b(?:exhaust\w*|exceed\w*|limit\w*|throttl\w*|reach\w*)\b[^\n]{0,40}\b(?:tpm|rpm)\b|\bRateLimitExceeded\.(?:Endpoint)?(?:TPM|RPM)\w*/i;
123
+
113
124
  const GOOGLE_RPC_ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo";
114
125
  const ANTIGRAVITY_MODEL_QUOTA_PATTERN = /\bexhausted your capacity on this model\b/i;
115
126
  const LONG_RATE_LIMIT_DELAY_MS = 5 * 60 * 1000;
@@ -180,8 +191,9 @@ function isQuotaExhaustedReason(reason: RateLimitReason): boolean {
180
191
  * Classify a rate-limit error message into a reason category.
181
192
  * Priority order: explicit details in a resource-exhausted error > QUOTA
182
193
  * (Antigravity "quota will reset") > CN quota > DASHSCOPE_TOKEN_LIMIT (TPM/TPS
183
- * throttle) > CONCURRENT_LIMIT > MODEL_CAPACITY > QUOTA (account) > RATE_LIMIT >
184
- * QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN.
194
+ * throttle) > CONCURRENT_LIMIT > MODEL_CAPACITY > QUOTA (account) > RATE_LIMIT
195
+ * (including TPM/RPM rolling windows) > QUOTA (generic) > SERVER_ERROR > bare
196
+ * resource-exhausted > UNKNOWN.
185
197
  *
186
198
  * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait).
187
199
  * Explicit details such as "quota exceeded" retain their normal classification.
@@ -252,7 +264,8 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
252
264
  lower.includes("per minute") ||
253
265
  lower.includes("rate limit") ||
254
266
  lower.includes("too many requests") ||
255
- lower.includes("presque")
267
+ lower.includes("presque") ||
268
+ TPM_RPM_THROTTLE_PATTERN.test(errorMessage)
256
269
  ) {
257
270
  return "RATE_LIMIT_EXCEEDED";
258
271
  }
@@ -411,6 +424,13 @@ export function matchesUsageLimitText(errorMessage: string): boolean {
411
424
  const structuredReason = parseGoogleRpcRateLimitReason(errorMessage);
412
425
  if (structuredReason !== undefined) return isQuotaExhaustedReason(structuredReason);
413
426
  if (isDashScopeTokenLimitText(errorMessage)) return false;
427
+ // Rolling TPM/RPM windows self-heal, so they never rotate a credential. The
428
+ // reason re-check is the precedence guard: an account-scoped cap that merely
429
+ // quotes a TPM number resolves to QUOTA_EXHAUSTED earlier in that ladder and
430
+ // keeps its usage-limit verdict.
431
+ if (TPM_RPM_THROTTLE_PATTERN.test(errorMessage) && parseRateLimitReason(errorMessage) === "RATE_LIMIT_EXCEEDED") {
432
+ return false;
433
+ }
414
434
  return (
415
435
  USAGE_LIMIT_PATTERN.test(errorMessage) ||
416
436
  ANTHROPIC_CREDITS_REQUIRED_PATTERN.test(errorMessage) ||
@@ -15,6 +15,7 @@ import {
15
15
  parseStreamingJsonThrottled,
16
16
  readSseEvents,
17
17
  } from "@oh-my-pi/pi-utils";
18
+ import { NO_AUTH_SENTINEL } from "../auth-retry";
18
19
  import { renderDemotedThinking } from "../dialect/demotion";
19
20
  import * as AIError from "../error";
20
21
  import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
@@ -404,10 +405,17 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
404
405
  };
405
406
  return allowAnthropicHeaderOverrides ? mergeHeaders(headers, anthropicHeaderOverrides) : headers;
406
407
  } else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
408
+ // A keyless provider (`auth: none`) resolves to the `N/A` sentinel
409
+ // rather than a real key; custom endpoints that authenticate via their
410
+ // own headers may reject a bogus bearer, so send no Authorization —
411
+ // same sentinel guard as the openai transports. A caller-supplied
412
+ // Authorization in `model.headers` still wins.
413
+ const bearer =
414
+ incomingAuthorization ?? (options.apiKey !== NO_AUTH_SENTINEL ? `Bearer ${options.apiKey}` : undefined);
407
415
  return {
408
416
  ...modelHeaders,
409
417
  Accept: acceptHeader,
410
- Authorization: incomingAuthorization ?? `Bearer ${options.apiKey}`,
418
+ ...(bearer ? { Authorization: bearer } : {}),
411
419
  ...sharedHeaders,
412
420
  ...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
413
421
  ...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
@@ -3723,8 +3731,13 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
3723
3731
  // the proxy to deal with two competing credentials when the user explicitly
3724
3732
  // asked for one.
3725
3733
  const authorizationHeader = getHeaderCaseInsensitive(defaultHeaders, "Authorization");
3734
+ // A keyless provider resolves to the `N/A` sentinel, for which no
3735
+ // Authorization was built above; the client would otherwise inject a
3736
+ // bogus `X-Api-Key: N/A` of its own.
3726
3737
  const shouldSuppressClientApiKey =
3727
- !oauthToken && !model.compat.officialEndpoint && typeof authorizationHeader === "string";
3738
+ !oauthToken &&
3739
+ !model.compat.officialEndpoint &&
3740
+ (typeof authorizationHeader === "string" || apiKey === NO_AUTH_SENTINEL);
3728
3741
 
3729
3742
  return {
3730
3743
  isOAuthToken: oauthToken,
@@ -4004,34 +4017,33 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4004
4017
  }
4005
4018
 
4006
4019
  /**
4007
- * Trailing system-prompt segments carrying per-turn volatile content (memory
4008
- * recall blocks). They are rendered by the coding agent as their own
4009
- * `systemPrompt` array elements and appended last, so on the wire they
4010
- * normally form a volatile suffix after the stable prefix. The system cache
4011
- * breakpoint anchors on the last stable segment instead of the array tail, so
4012
- * a recall refresh re-bills only the suffix and the message tail for one turn
4013
- * while the tools+stable-system prefix stays a cache hit.
4020
+ * System-prompt segments whose bytes differ between sessions or turns of the
4021
+ * same agent: per-turn memory recall (`<memories>`) and the coding agent's
4022
+ * working-directory context (`<project-context>`: context files with their
4023
+ * paths, workspace tree, workspace roots, session append text; advisors use
4024
+ * the same tag for their context-file block). The coding agent renders them
4025
+ * as their own `systemPrompt` array elements after the large static prompt.
4026
+ * The system cache breakpoint anchors on the block right before the first
4027
+ * such segment, so the static head is shared byte-for-byte across sessions in
4028
+ * different directories (e.g. one git worktree per task) and a recall refresh
4029
+ * re-bills only the suffix and the message tail.
4014
4030
  *
4015
- * Only a genuinely trailing volatile run counts: a `before_agent_start`
4016
- * extension override may append a stable policy block after the staged recall
4017
- * block, and that block stays in the cached head. A volatile block stranded
4018
- * mid-array still poisons the prefix at its position — prefix caching is
4019
- * positional, so no classification can save the bytes after it.
4031
+ * Everything from the first volatile segment on sits after the head
4032
+ * breakpoint, including stable blocks appended behind it (per-spawn subagent
4033
+ * role text, `before_agent_start` extension policy): prefix caching is
4034
+ * positional, so bytes after a changing segment can never extend the cached
4035
+ * head anyway; the rolling message breakpoints still cover them.
4020
4036
  *
4021
- * Detection is by our own markup, not model identity: recall blocks always
4022
- * open with `<memories>`. Stable segments containing recalled text elsewhere
4023
- * (e.g. quoted in conversation) are unaffected — only a leading tag counts.
4037
+ * Detection is by our own markup, not model identity: only a leading tag
4038
+ * counts, so stable segments quoting these tags elsewhere are unaffected.
4024
4039
  */
4025
- const VOLATILE_SYSTEM_SEGMENT_MARKERS = ["<memories>"];
4040
+ const VOLATILE_SYSTEM_SEGMENT_MARKERS = ["<memories>", "<project-context>"];
4026
4041
 
4027
- function stableSystemSuffixStart(systemBlocks: readonly AnthropicSystemBlock[]): number {
4028
- let start = systemBlocks.length;
4029
- while (start > 0) {
4030
- const text = systemBlocks[start - 1]?.text ?? "";
4031
- if (!VOLATILE_SYSTEM_SEGMENT_MARKERS.some(marker => text.startsWith(marker))) break;
4032
- start--;
4033
- }
4034
- return start;
4042
+ function volatileSystemSuffixStart(systemBlocks: readonly AnthropicSystemBlock[]): number {
4043
+ const start = systemBlocks.findIndex(block =>
4044
+ VOLATILE_SYSTEM_SEGMENT_MARKERS.some(marker => block.text.startsWith(marker)),
4045
+ );
4046
+ return start === -1 ? systemBlocks.length : start;
4035
4047
  }
4036
4048
 
4037
4049
  /**
@@ -4045,10 +4057,10 @@ function stableSystemSuffixStart(systemBlocks: readonly AnthropicSystemBlock[]):
4045
4057
  * (Claude Code, Pi) use. Without it, the general API-key path anchors only the
4046
4058
  * moving message tail, so tail churn re-writes the whole head uncached.
4047
4059
  *
4048
- * Volatile trailing segments (memory recall) sit after the breakpoint, so a
4049
- * recall refresh re-bills only the suffix and the tail for one turn instead of
4050
- * the whole head. When every system block is volatile there is no stable
4051
- * boundary and the breakpoint stays on the array tail (previous behavior).
4060
+ * Volatile segments (memory recall, working-directory context) sit after the
4061
+ * breakpoint, so a recall refresh or a different cwd re-bills only the suffix
4062
+ * and the tail instead of the whole head. When every system block is volatile
4063
+ * there is no stable boundary and the breakpoint stays on the array tail.
4052
4064
  *
4053
4065
  * Anthropic allows at most 4 cache breakpoints per request. At most one is
4054
4066
  * spent on tools and one on system here, leaving the remaining budget for
@@ -4058,9 +4070,12 @@ function stableSystemSuffixStart(systemBlocks: readonly AnthropicSystemBlock[]):
4058
4070
  * sit first in wire order and survive message rewrites, and sibling subagents of
4059
4071
  * the same definition share this prefix byte for byte.
4060
4072
  *
4061
- * When the OAuth Claude Code path already anchors its identity system block at
4062
- * buildAnthropicSystemBlocks, the system check skips adding a second system
4063
- * breakpoint, while the tool check still anchors the last tool definition.
4073
+ * The OAuth Claude Code path pre-decorates its identity system block in
4074
+ * buildAnthropicSystemBlocks. That breakpoint moves to the anchor block instead
4075
+ * of a second one being added: the identity block is a prefix of the anchored
4076
+ * head, so the move keeps the budget at last tool + last stable system block +
4077
+ * 2 message breakpoints while the agent's static system prompt, not just the
4078
+ * identity line, becomes a cached prefix of its own.
4064
4079
  *
4065
4080
  * Runs on the fresh system blocks and wire tools built for this request, after
4066
4081
  * the declared tool list was derived from the transcript's request controls.
@@ -4084,26 +4099,20 @@ function applyHeadCaching(
4084
4099
  }
4085
4100
 
4086
4101
  if (systemBlocks && systemBlocks.length > 0) {
4087
- // Anchor on the last stable block so a volatile recall suffix refresh
4088
- // re-bills only the suffix, not the whole head. The skip-if-decorated
4089
- // check applies only when there is no volatile suffix (previous
4090
- // behavior): with a suffix present the boundary anchor is added
4091
- // whenever the anchor block itself lacks a breakpoint, even if the
4092
- // OAuth path pre-decorated its identity block — otherwise the only
4093
- // system breakpoint sits before the stable prompt and a recall
4094
- // refresh re-bills it. The message budget in `applyPromptCaching`
4095
- // shrinks accordingly (4 minus head breakpoints). All-volatile falls
4096
- // back to tail anchoring (previous behavior).
4097
- const suffixStart = stableSystemSuffixStart(systemBlocks);
4098
- if (suffixStart === systemBlocks.length) {
4099
- if (!systemBlocks.some(block => block.cache_control != null)) {
4100
- const lastBlock = systemBlocks[systemBlocks.length - 1];
4101
- if (lastBlock) lastBlock.cache_control = cloneAnthropicCacheControl(cacheControl);
4102
- }
4103
- } else {
4104
- const anchorIndex = suffixStart === 0 ? systemBlocks.length - 1 : suffixStart - 1;
4105
- const anchor = systemBlocks[anchorIndex];
4106
- if (anchor && anchor.cache_control == null) anchor.cache_control = cloneAnthropicCacheControl(cacheControl);
4102
+ // Anchor on the last block before the volatile suffix so a recall
4103
+ // refresh or a different working directory re-bills only the suffix,
4104
+ // not the whole head. An earlier system breakpoint (the OAuth identity
4105
+ // block) moves to the anchor rather than staying as a second one: a
4106
+ // breakpoint left on the identity block caches only tools + identity,
4107
+ // and keeping both would take a rolling message breakpoint from
4108
+ // `applyPromptCaching` (4 minus head breakpoints). All-volatile falls
4109
+ // back to tail anchoring.
4110
+ const suffixStart = volatileSystemSuffixStart(systemBlocks);
4111
+ const anchorIndex = suffixStart === 0 ? systemBlocks.length - 1 : suffixStart - 1;
4112
+ const anchor = systemBlocks[anchorIndex];
4113
+ if (anchor && anchor.cache_control == null) {
4114
+ for (const block of systemBlocks) delete block.cache_control;
4115
+ anchor.cache_control = cloneAnthropicCacheControl(cacheControl);
4107
4116
  }
4108
4117
  }
4109
4118
  }
@@ -125,11 +125,53 @@ function decodedResponseStream(message: IncomingMessage): stream.Readable {
125
125
  return stream.pipeline(message, decoder, () => {});
126
126
  }
127
127
 
128
- function createResponse(message: IncomingMessage, method: string): Response {
128
+ /**
129
+ * Bun's `node:http` shim reports a response body cut off mid-stream (peer reset,
130
+ * dead connection) as a bare `Error("aborted")` with code `ECONNRESET` — wording
131
+ * indistinguishable from a cancellation, so retry classification treated every
132
+ * mid-stream drop on this transport as terminal. Native `fetch` reports the same
133
+ * failure as "The socket connection was closed unexpectedly", a recognized
134
+ * transient. Re-raise with that wording, keeping the original as `cause`; a
135
+ * caller abort keeps its own error.
136
+ */
137
+ function withFetchParityErrors(
138
+ body: ReadableStream<Uint8Array>,
139
+ signal: AbortSignal | undefined,
140
+ ): ReadableStream<Uint8Array> {
141
+ const reader = body.getReader();
142
+ return new ReadableStream<Uint8Array>({
143
+ async pull(controller) {
144
+ try {
145
+ const chunk = await reader.read();
146
+ if (chunk.done) controller.close();
147
+ else controller.enqueue(chunk.value);
148
+ } catch (error) {
149
+ const prematureClose =
150
+ !signal?.aborted &&
151
+ error instanceof Error &&
152
+ error.message === "aborted" &&
153
+ (error as NodeJS.ErrnoException).code === "ECONNRESET";
154
+ controller.error(
155
+ prematureClose
156
+ ? Object.assign(
157
+ new Error("The socket connection was closed unexpectedly before the response completed", {
158
+ cause: error,
159
+ }),
160
+ { code: "ECONNRESET" },
161
+ )
162
+ : error,
163
+ );
164
+ }
165
+ },
166
+ cancel: reason => reader.cancel(reason),
167
+ });
168
+ }
169
+
170
+ function createResponse(message: IncomingMessage, method: string, signal: AbortSignal | undefined): Response {
129
171
  const status = message.statusCode;
130
172
  if (status === undefined) throw new Error("Cowork transport received a response without an HTTP status.");
131
173
  const hasBody = method !== "HEAD" && status !== 204 && status !== 304;
132
- const body = hasBody ? stream.Readable.toWeb(decodedResponseStream(message)) : null;
174
+ const body = hasBody ? withFetchParityErrors(stream.Readable.toWeb(decodedResponseStream(message)), signal) : null;
133
175
  return new Response(body, {
134
176
  status,
135
177
  statusText: message.statusMessage,
@@ -190,7 +232,7 @@ async function sendCoworkRequest(
190
232
  });
191
233
  }
192
234
  try {
193
- result.resolve(createResponse(message, method));
235
+ result.resolve(createResponse(message, method, signal));
194
236
  } catch (error) {
195
237
  message.destroy();
196
238
  release();
@@ -74,7 +74,7 @@ import type { ToolResultMessage } from "../../types";
74
74
  * and their translation are consumed together.
75
75
  */
76
76
  export {
77
- cursorEditOwnedReadPath,
77
+ cursorExecReadPath,
78
78
  cursorRawReadPath,
79
79
  omitUndefinedArgs,
80
80
  piEscapeRegexLiteral,
@@ -25,18 +25,14 @@ import * as path from "node:path";
25
25
  * A `pi_read` range composed onto the path as `read`'s inline `:raw:N+K`
26
26
  * selector.
27
27
  *
28
- * `read` exposes no range kwargs, so an uncomposed range reads the whole file.
29
- * `offset` is a 1-indexed start clamped like the reference's
30
- * `Math.max(0, offset - 1)` over 0-indexed lines; `limit` is a line count.
31
- * `null` marks a present `limit: 0` — zero lines, which no selector expresses
32
- * and which must not degrade into a whole-file read.
33
- *
34
- * The range is `raw` because a plain `:N+K` deliberately pads with one leading
35
- * and three trailing context lines: helpful for a human reading a snippet,
36
- * wrong for a caller that asked for exactly `limit` lines from `offset`. The
37
- * wire result is an opaque `output` string, so the hashline and line-number
38
- * gutter that `raw` also drops carry nothing the frame's contract needs.
39
- * A range-free read keeps the ordinary form — whole-file reads want them.
28
+ * `read` exposes no range kwargs; `offset` and `limit` are composed onto the
29
+ * path. A negative offset needs the source line count and is resolved by the
30
+ * coding-agent bridge before calling this helper. `limit: 0` has no selector
31
+ * representation and returns `null`.
32
+ *
33
+ * Range selectors are raw because plain ranges add context lines. Cursor
34
+ * numbers the returned text itself, so it cannot use read's hashline gutter.
35
+ * Use [`cursorExecReadPath`] for a range-free read, which also needs `:raw`.
40
36
  */
41
37
  export function piReadPath(readPath: string, offset?: number, limit?: number): string | null {
42
38
  if (limit !== undefined && Math.floor(limit) <= 0) return null;
@@ -100,14 +96,13 @@ export function cursorRawReadPath(readPath: string): string {
100
96
  }
101
97
 
102
98
  /**
103
- * Path the edit-owned materialization read should execute.
99
+ * Raw selector for a Cursor exec read, including edit-owned materialization.
104
100
  *
105
- * Range is composed first (`piReadPath` already uses `:raw` for a range),
106
- * then a whole-file path is forced onto `:raw`. The caller must drop
107
- * `offset`/`limit` after this so the bridge's `piReadPath` cannot append a
108
- * second `:raw` onto the already-composed selector.
101
+ * Compose a requested window before forcing `:raw` on whole-file reads. The
102
+ * caller drops `offset`/`limit` after composing so the handler cannot append
103
+ * another selector.
109
104
  */
110
- export function cursorEditOwnedReadPath(readPath: string, offset?: number, limit?: number): string | null {
105
+ export function cursorExecReadPath(readPath: string, offset?: number, limit?: number): string | null {
111
106
  const ranged = piReadPath(readPath, offset, limit);
112
107
  if (ranged === null) return null;
113
108
  return cursorRawReadPath(ranged);