@oh-my-pi/pi-ai 17.1.6 → 17.1.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.7] - 2026-07-27
6
+
7
+ ### Changed
8
+
9
+ - Upstream `403 Forbidden` responses (e.g. Anthropic `permission_error` plan/model denials, Copilot model-policy rejections) now rotate through sibling credentials like usage limits do, instead of failing the session on the first denied account. The denied credential is soft-blocked for 60s and re-validated — never removed — and the original 403 surfaces only once every sibling has been tried.
10
+ - Usage report filtering in the auth-broker remote store is memoized per (reports, snapshot) with a precomputed per-provider OAuth credential map, replacing an O(reports × credentials) scan on every credential-selection and status refresh
11
+ - Cursor and Devin Connect-frame readers no longer copy every stream chunk through `Buffer.concat` when the pending buffer is empty
12
+
5
13
  ## [17.1.6] - 2026-07-27
6
14
 
7
15
  ### Added
@@ -14,7 +14,7 @@ import { isAuthRetryableError } from "./error/auth-classify.js";
14
14
  * (invalidate/usage-limit the current credential and rotate to a sibling).
15
15
  *
16
16
  * Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
17
- * failures. Usage/account-limit failures skip refresh and may repeat step (c)
17
+ * failures. 403/usage-limit failures skip refresh and may repeat step (c)
18
18
  * until the resolver returns `undefined`, cycles, or hits
19
19
  * {@link AUTH_RETRY_MAX_ATTEMPTS}.
20
20
  */
@@ -84,8 +84,8 @@ export declare function resolveNextAuthRetryKey(state: AuthRetryKeyState, resolv
84
84
  * - A resolver → initial `attempt`, then resolver-driven retries until the
85
85
  * applicable policy is exhausted, the resolver declines or cycles, or the
86
86
  * operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. Ordinary 401/auth
87
- * failures retain one refresh-same plus one sibling switch; usage/account
88
- * limits rotate directly through distinct siblings.
87
+ * failures retain one refresh-same plus one sibling switch; 403/usage-limit
88
+ * failures rotate directly through distinct siblings.
89
89
  *
90
90
  * Used by non-streaming consumers (image generation, web search, completion
91
91
  * helpers). The streaming driver in `stream.ts` implements the same policy with
@@ -136,7 +136,7 @@ export interface WithOAuthAccessOptions {
136
136
  * - initial → `getOAuthAccess` (or `opts.seed`).
137
137
  * - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
138
138
  * current account, then sibling rotation.
139
- * - usage-limit failure → `rotateSessionCredential` directly, without a
139
+ * - 403/usage-limit failure → `rotateSessionCredential` directly, without a
140
140
  * force-refresh detour.
141
141
  *
142
142
  * A refresh-same step may retry a new bearer for the same credential identity;
@@ -9,9 +9,11 @@ export declare function isDefinitiveOAuthFailure(errorMsg: string): boolean;
9
9
  export declare function isInvalidatedOAuthTokenError(error: unknown): boolean;
10
10
  /**
11
11
  * Whether an upstream failure should rotate to a sibling credential: a hard
12
- * `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
13
- * account rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`,
14
- * …), or a bare `429` whose payload did not preserve a richer quota code.
12
+ * `401`, a `403` (token valid but access denied — plan, model policy, or org
13
+ * restriction a sibling account may not share), a body-classified usage limit
14
+ * (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
15
+ * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
16
+ * whose payload did not preserve a richer quota code.
15
17
  * Transient 429s (`Too many requests`, per-minute caps) stay in the
16
18
  * upstream-backoff lane.
17
19
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.1.6",
4
+ "version": "17.1.7",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.1.6",
42
- "@oh-my-pi/pi-utils": "17.1.6",
43
- "@oh-my-pi/pi-wire": "17.1.6",
41
+ "@oh-my-pi/pi-catalog": "17.1.7",
42
+ "@oh-my-pi/pi-utils": "17.1.7",
43
+ "@oh-my-pi/pi-wire": "17.1.7",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -253,6 +253,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
253
253
  #usageInflight?: Promise<UsageReport[] | null>;
254
254
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
255
255
  #usageCacheEpoch = 0;
256
+ /** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
257
+ #usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
258
+ /** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
259
+ #usageFilterResult?: { input: UsageReport[]; byProvider: Map<Provider, OAuthCredential[]>; output: UsageReport[] };
256
260
  #closed = false;
257
261
  /**
258
262
  * `true` once the SSE consumer received its first frame and hasn't dropped
@@ -962,18 +966,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
962
966
  return overlay ?? matched;
963
967
  }
964
968
 
969
+ /**
970
+ * Hot path — called per `getUsageReport()`/`fetchUsageReports()` (status-line
971
+ * refresh cadence). The oauth-credential lookup is memoized on `#snapshot`
972
+ * identity (every update site replaces the reference), and the filtered
973
+ * output on (reports identity, lookup identity) — `#loadUsageReports`
974
+ * serves the same array for 15s, so steady-state calls are O(1).
975
+ */
965
976
  #filterUsageReports(reports: UsageReport[]): UsageReport[] {
966
977
  const accountPool = this.#accountPool;
967
978
  if (!accountPool) return reports;
968
- return reports.filter(report => {
979
+ let lookup = this.#usageFilterLookup;
980
+ if (!lookup || lookup.snapshot !== this.#snapshot) {
981
+ const byProvider = new Map<Provider, OAuthCredential[]>();
982
+ for (const entry of this.#snapshot.credentials) {
983
+ if (entry.credential.type !== "oauth") continue;
984
+ const list = byProvider.get(entry.provider);
985
+ if (list) list.push(entry.credential);
986
+ else byProvider.set(entry.provider, [entry.credential]);
987
+ }
988
+ lookup = { snapshot: this.#snapshot, byProvider };
989
+ this.#usageFilterLookup = lookup;
990
+ }
991
+ const memo = this.#usageFilterResult;
992
+ if (memo && memo.input === reports && memo.byProvider === lookup.byProvider) return memo.output;
993
+ const byProvider = lookup.byProvider;
994
+ const output = reports.filter(report => {
969
995
  if (!accountPool.has(report.provider)) return true;
970
- return this.#snapshot.credentials.some(
971
- entry =>
972
- entry.provider === report.provider &&
973
- entry.credential.type === "oauth" &&
974
- usageReportMatchesCredential(report, entry.credential),
975
- );
996
+ const credentials = byProvider.get(report.provider);
997
+ if (!credentials) return false;
998
+ return credentials.some(credential => usageReportMatchesCredential(report, credential));
976
999
  });
1000
+ this.#usageFilterResult = { input: reports, byProvider, output };
1001
+ return output;
977
1002
  }
978
1003
 
979
1004
  ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
package/src/auth-retry.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
1
2
  import type { OAuthAccess } from "./auth-storage";
2
3
  import * as AIError from "./error";
3
4
  import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
@@ -18,7 +19,7 @@ import { isUsageLimitOutcome } from "./error/rate-limit";
18
19
  * (invalidate/usage-limit the current credential and rotate to a sibling).
19
20
  *
20
21
  * Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
21
- * failures. Usage/account-limit failures skip refresh and may repeat step (c)
22
+ * failures. 403/usage-limit failures skip refresh and may repeat step (c)
22
23
  * until the resolver returns `undefined`, cycles, or hits
23
24
  * {@link AUTH_RETRY_MAX_ATTEMPTS}.
24
25
  */
@@ -92,7 +93,11 @@ export const AUTH_RETRY_MAX_ATTEMPTS = 64;
92
93
  function isDirectCredentialRotationError(error: unknown): boolean {
93
94
  if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
94
95
  const status = AIError.status(error);
96
+ // 403: the token is valid but access was denied, so refreshing the same
97
+ // credential can't help — rotate straight through the sibling pool.
98
+ if (status === 403) return true;
95
99
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
100
+ if (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403) return true;
96
101
  return isUsageLimitOutcome(status, message);
97
102
  }
98
103
 
@@ -199,8 +204,8 @@ async function runOAuthAttempt<T>(
199
204
  * - A resolver → initial `attempt`, then resolver-driven retries until the
200
205
  * applicable policy is exhausted, the resolver declines or cycles, or the
201
206
  * operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. Ordinary 401/auth
202
- * failures retain one refresh-same plus one sibling switch; usage/account
203
- * limits rotate directly through distinct siblings.
207
+ * failures retain one refresh-same plus one sibling switch; 403/usage-limit
208
+ * failures rotate directly through distinct siblings.
204
209
  *
205
210
  * Used by non-streaming consumers (image generation, web search, completion
206
211
  * helpers). The streaming driver in `stream.ts` implements the same policy with
@@ -289,7 +294,7 @@ export interface WithOAuthAccessOptions {
289
294
  * - initial → `getOAuthAccess` (or `opts.seed`).
290
295
  * - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
291
296
  * current account, then sibling rotation.
292
- * - usage-limit failure → `rotateSessionCredential` directly, without a
297
+ * - 403/usage-limit failure → `rotateSessionCredential` directly, without a
293
298
  * force-refresh detour.
294
299
  *
295
300
  * A refresh-same step may retry a new bearer for the same credential identity;
@@ -26,9 +26,11 @@ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
26
26
 
27
27
  /**
28
28
  * Whether an upstream failure should rotate to a sibling credential: a hard
29
- * `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
30
- * account rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`,
31
- * …), or a bare `429` whose payload did not preserve a richer quota code.
29
+ * `401`, a `403` (token valid but access denied — plan, model policy, or org
30
+ * restriction a sibling account may not share), a body-classified usage limit
31
+ * (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
32
+ * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
33
+ * whose payload did not preserve a richer quota code.
32
34
  * Transient 429s (`Too many requests`, per-minute caps) stay in the
33
35
  * upstream-backoff lane.
34
36
  */
@@ -36,9 +38,9 @@ export function isAuthRetryableError(error: unknown): boolean {
36
38
  if (isUsageLimit(error)) return true;
37
39
  if (isInvalidatedOAuthTokenError(error)) return true;
38
40
  const httpStatus = extractHttpStatusFromError(error);
39
- if (httpStatus === 401) return true;
41
+ if (httpStatus === 401 || httpStatus === 403) return true;
40
42
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
41
43
  const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
42
- if (embeddedStatus === 401) return true;
44
+ if (embeddedStatus === 401 || embeddedStatus === 403) return true;
43
45
  return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message);
44
46
  }
@@ -503,7 +503,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
503
503
 
504
504
  stream.push({ type: "start", partial: output });
505
505
 
506
- let pendingBuffer = Buffer.alloc(0);
506
+ let pendingBuffer: Buffer = Buffer.alloc(0);
507
507
  let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
508
508
  let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
509
509
  let currentToolCall: ToolCallState | null = null;
@@ -557,7 +557,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
557
557
  log?.write(chunk);
558
558
  });
559
559
  }
560
- pendingBuffer = Buffer.concat([pendingBuffer, chunk]);
560
+ // Steady state drains fully per chunk; alias the fresh h2 chunk instead
561
+ // of copying it through Buffer.concat (see aws-eventstream.ts).
562
+ pendingBuffer = pendingBuffer.length === 0 ? chunk : Buffer.concat([pendingBuffer, chunk]);
561
563
 
562
564
  while (pendingBuffer.length >= 5) {
563
565
  const flags = pendingBuffer[0];
@@ -217,7 +217,12 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
217
217
  for (;;) {
218
218
  const { done, value } = await reader.read();
219
219
  if (value && value.length > 0) {
220
- pending = Buffer.concat([pending, value]);
220
+ // Steady state drains fully per chunk; view the fresh reader chunk
221
+ // instead of copying it through Buffer.concat (see aws-eventstream.ts).
222
+ pending =
223
+ pending.length === 0
224
+ ? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
225
+ : Buffer.concat([pending, value]);
221
226
  }
222
227
 
223
228
  while (pending.length >= 5) {
package/src/stream.ts CHANGED
@@ -970,7 +970,9 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
970
970
  }
971
971
 
972
972
  function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
973
- // 401 means the credential is bad. Usage-limit phrasing (Codex's
973
+ // 401 means the credential is bad; 403 is its valid-token twin (access
974
+ // denied by plan, model policy, or org restriction — a sibling account may
975
+ // not share it). Usage-limit phrasing (Codex's
974
976
  // "You have hit your ChatGPT usage limit", Anthropic's "usage_limit_reached",
975
977
  // Google's "resource_exhausted", OpenAI's "insufficient_quota") and 429s
976
978
  // without transient rate-limit wording mean this account is parked but a
@@ -983,7 +985,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
983
985
  // instead of burning siblings.
984
986
  if (AIError.isUsageLimit(error)) return true;
985
987
  if (isInvalidatedOAuthTokenError(error)) return true;
986
- if (status === 401) return true;
988
+ if (status === 401 || status === 403) return true;
987
989
  return isUsageLimitOutcome(status, message);
988
990
  }
989
991