@oh-my-pi/pi-ai 17.2.10 → 17.2.12

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 (64) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/auth-retry.d.ts +3 -3
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +5 -5
  5. package/dist/types/error/flags.d.ts +8 -4
  6. package/dist/types/registry/api-key-login.d.ts +6 -4
  7. package/dist/types/registry/cloudflare-ai-gateway.d.ts +2 -2
  8. package/dist/types/registry/kagi.d.ts +2 -2
  9. package/dist/types/registry/litellm.d.ts +2 -2
  10. package/dist/types/registry/llama-cpp.d.ts +2 -2
  11. package/dist/types/registry/lm-studio.d.ts +2 -2
  12. package/dist/types/registry/oauth/cursor.d.ts +1 -0
  13. package/dist/types/registry/oauth/index.d.ts +4 -0
  14. package/dist/types/registry/parallel.d.ts +2 -2
  15. package/dist/types/registry/tavily.d.ts +1 -1
  16. package/dist/types/registry/vercel-ai-gateway.d.ts +2 -2
  17. package/dist/types/registry/vllm.d.ts +2 -2
  18. package/dist/types/types.d.ts +10 -2
  19. package/dist/types/usage/cursor.d.ts +1 -0
  20. package/dist/types/usage/openai-codex-reset.d.ts +0 -20
  21. package/dist/types/usage/shared.d.ts +13 -1
  22. package/dist/types/utils/http-inspector.d.ts +4 -5
  23. package/dist/types/utils/retry.d.ts +2 -3
  24. package/package.json +5 -5
  25. package/src/auth/sqlite-credential-store.ts +17 -0
  26. package/src/auth-retry.ts +5 -4
  27. package/src/auth-storage.ts +77 -33
  28. package/src/error/auth-classify.ts +7 -6
  29. package/src/error/flags.ts +27 -15
  30. package/src/error/rate-limit.ts +53 -1
  31. package/src/providers/anthropic.ts +14 -4
  32. package/src/providers/register-builtins.ts +15 -2
  33. package/src/registry/api-key-login.ts +26 -12
  34. package/src/registry/cloudflare-ai-gateway.ts +10 -29
  35. package/src/registry/kagi.ts +11 -29
  36. package/src/registry/litellm.ts +11 -29
  37. package/src/registry/llama-cpp.ts +11 -21
  38. package/src/registry/lm-studio.ts +9 -20
  39. package/src/registry/oauth/cursor.ts +29 -13
  40. package/src/registry/oauth/index.ts +7 -0
  41. package/src/registry/parallel.ts +10 -28
  42. package/src/registry/tavily.ts +9 -27
  43. package/src/registry/vercel-ai-gateway.ts +10 -28
  44. package/src/registry/vllm.ts +11 -21
  45. package/src/stream.ts +21 -11
  46. package/src/types.ts +24 -14
  47. package/src/usage/alibaba-token-plan.ts +8 -15
  48. package/src/usage/claude.ts +12 -19
  49. package/src/usage/cursor.ts +178 -47
  50. package/src/usage/gemini.ts +3 -2
  51. package/src/usage/github-copilot.ts +3 -2
  52. package/src/usage/google-antigravity.ts +10 -17
  53. package/src/usage/kimi.ts +6 -12
  54. package/src/usage/minimax-code.ts +7 -27
  55. package/src/usage/openai-codex-reset.ts +1 -1
  56. package/src/usage/openai-codex.ts +3 -4
  57. package/src/usage/opencode-go.ts +1 -2
  58. package/src/usage/shared.ts +33 -10
  59. package/src/usage/synthetic.ts +4 -11
  60. package/src/usage/umans.ts +2 -2
  61. package/src/usage/xai-oauth.ts +13 -26
  62. package/src/usage/zai.ts +2 -5
  63. package/src/utils/http-inspector.ts +4 -5
  64. package/src/utils/retry.ts +2 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.12] - 2026-08-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed account-scoped Codex cyber-policy denials bypassing sibling credential rotation; replay-safe requests now try every configured account before surfacing the error.
10
+
11
+ ## [17.2.11] - 2026-08-07
12
+
13
+ ### Breaking Changes
14
+
15
+ - Fixed handling of GitHub Copilot's model_not_available_for_integrator error to prevent unnecessary retries, preserving the actionable available models list.
16
+
17
+ ### Added
18
+
19
+ - Added support for reporting Cursor personal monthly USD quotas and remaining balances, labeled by verified profile email accounts.
20
+
21
+ ### Fixed
22
+
23
+ - Fixed an issue where ANTHROPIC_BASE_URL was ignored for Anthropic chat requests, ensuring requests are routed to the configured host and forwarding ANTHROPIC_CUSTOM_HEADERS to non-official gateways.
24
+ - Fixed an issue where a legacy pre-organization login credential could persist and cause a permanent error row in omp usage even after a successful organization-scoped re-login.
25
+ - Fixed an issue where lazy provider streams (including Amazon Bedrock, Google, Cursor, Devin, and Ollama) ignored model-specific idle timeouts, which previously caused healthy but slow reasoning turns to prematurely time out.
26
+ - Improved error classification for Simplified Chinese quota-exhaustion and rate-limit messages, ensuring affected credentials are correctly rotated or backed off instead of being treated as unknown errors.
27
+ - Classified subscription and plan-cap 429 responses as rotatable usage limits rather than transient rate-limit throttles, enabling smoother credential rotation.
28
+
5
29
  ## [17.2.10] - 2026-08-06
6
30
 
7
31
  ### Breaking Changes
@@ -14,9 +14,9 @@ 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. 403/usage-limit failures skip refresh and may repeat step (c)
18
- * until the resolver returns `undefined`, cycles, or hits
19
- * {@link AUTH_RETRY_MAX_ATTEMPTS}.
17
+ * failures. Account-scoped policy denials, 403s, and usage-limit failures skip
18
+ * refresh and may repeat step (c) until the resolver returns `undefined`,
19
+ * cycles, or hits {@link AUTH_RETRY_MAX_ATTEMPTS}.
20
20
  */
21
21
  export interface ApiKeyResolveContext {
22
22
  /** True when the resolver should rotate to a sibling credential. */
@@ -1143,6 +1143,8 @@ export declare class AuthStorage {
1143
1143
  * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
1144
1144
  * (temporary block via its own backoff — default plus server usage-report
1145
1145
  * reset; sticky left intact so the next resolve re-ranks around the block).
1146
+ * - account-scoped policy denial → temporarily block that account without
1147
+ * marking its credential suspect, then rotate through eligible siblings.
1146
1148
  * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
1147
1149
  * reload when no broker hook is wired) and block it, then drop matching
1148
1150
  * sticky state.
@@ -10,11 +10,11 @@ export declare function isInvalidatedOAuthTokenError(error: unknown): boolean;
10
10
  /**
11
11
  * Whether an upstream failure should rotate to a sibling credential: a hard
12
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
13
+ * restriction a sibling account may not share), an account-scoped policy
14
+ * denial such as Codex `cyber_policy`, a body-classified usage limit (Codex
15
+ * `usage_limit_reached`, Anthropic account rate-limit, Google
15
16
  * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
16
- * whose payload did not preserve a richer quota code.
17
- * Transient 429s (`Too many requests`, per-minute caps) stay in the
18
- * upstream-backoff lane.
17
+ * whose payload did not preserve a richer quota code. Transient 429s
18
+ * (`Too many requests`, per-minute caps) stay in the upstream-backoff lane.
19
19
  */
20
20
  export declare function isAuthRetryableError(error: unknown): boolean;
@@ -9,6 +9,8 @@ export declare const Flag: {
9
9
  readonly MalformedFunctionCall: 2097152;
10
10
  readonly ProviderFinishError: 4194304;
11
11
  readonly ContentBlocked: 32768;
12
+ /** Account-scoped provider policy denial that may succeed with another credential. */
13
+ readonly AccountPolicy: 16384;
12
14
  readonly ContextOverflow: 8388608;
13
15
  readonly AuthFailed: 16777216;
14
16
  readonly SilentAbort: 33554432;
@@ -53,6 +55,8 @@ export declare function classify(error: unknown, api?: Api): number;
53
55
  * regexes at call sites.
54
56
  */
55
57
  export declare function isUsageLimit(error: unknown, api?: Api): boolean;
58
+ /** Whether an upstream rejection is an account-scoped policy denial worth retrying with a sibling credential. */
59
+ export declare function isAccountPolicyError(error: unknown, api?: Api): boolean;
56
60
  /**
57
61
  * Strict-tool rejection: grammar too large, schema too complex, or structured
58
62
  * outputs unsupported by the model/endpoint.
@@ -65,10 +69,10 @@ export declare function isGrammarError(error: unknown): boolean;
65
69
  */
66
70
  export declare function isFastModeUnsupported(error: unknown): boolean;
67
71
  /**
68
- * GitHub Copilot 400 rejecting a model its own `/models` catalog advertises —
69
- * transient fleet skew, not a malformed request. Reads the structural `code`
70
- * through the SDK/body envelopes, then falls back to the stringified body both
71
- * SDK families put in `message` (shapes drift; the wire text does not).
72
+ * GitHub Copilot 400 `model_not_supported` response for a model advertised by
73
+ * `/models` — transient fleet skew, not a malformed request. Reads the
74
+ * structural `code` through the SDK/body envelopes, then falls back to the
75
+ * stringified body both SDK families put in `message`.
72
76
  */
73
77
  export declare function isCopilotTransientModelError(error: unknown): boolean;
74
78
  export declare function classifyMessage(message: {
@@ -27,16 +27,18 @@ type ModelsEndpointValidation = {
27
27
  export type ApiKeyLoginConfig = {
28
28
  /** Display name used in error messages, e.g. "Cerebras", "NanoGPT". */
29
29
  providerLabel: string;
30
- /** URL opened in browser for the user to grab their key. */
31
- authUrl: string;
32
- /** Instructions shown with the onAuth callback. */
33
- instructions: string;
30
+ /** URL opened in browser for the user to grab their key, or omitted to skip onAuth. */
31
+ authUrl?: string;
32
+ /** Instructions shown with the onAuth callback, or omitted to skip onAuth. */
33
+ instructions?: string;
34
34
  /** Prompt message shown when asking for the key paste. */
35
35
  promptMessage: string;
36
36
  /** Placeholder string for the prompt (e.g. "sk-...", "csk-..."). */
37
37
  placeholder: string;
38
38
  /** Validation strategy, or `null` to skip validation. */
39
39
  validation: ChatCompletionsValidation | AnthropicMessagesValidation | ModelsEndpointValidation | null;
40
+ /** Value returned for an empty key; also allows an empty prompt response. */
41
+ emptyKeyFallback?: string;
40
42
  };
41
43
  export declare function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise<string>;
42
44
  export {};
@@ -1,11 +1,11 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  /**
3
3
  * Login to Cloudflare AI Gateway.
4
4
  *
5
5
  * Opens browser to Cloudflare AI Gateway authentication docs and prompts for a gateway token/API key.
6
6
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
7
7
  */
8
- export declare function loginCloudflareAiGateway(options: OAuthController): Promise<string>;
8
+ export declare const loginCloudflareAiGateway: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
9
9
  export declare const cloudflareAiGatewayProvider: {
10
10
  readonly id: "cloudflare-ai-gateway";
11
11
  readonly name: "Cloudflare AI Gateway";
@@ -1,11 +1,11 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  /**
3
3
  * Login to Kagi.
4
4
  *
5
5
  * Opens browser to API settings and prompts user to paste their API key.
6
6
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
7
7
  */
8
- export declare function loginKagi(options: OAuthController): Promise<string>;
8
+ export declare const loginKagi: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
9
9
  export declare const kagiProvider: {
10
10
  readonly id: "kagi";
11
11
  readonly name: "Kagi";
@@ -1,11 +1,11 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  /**
3
3
  * Login to LiteLLM.
4
4
  *
5
5
  * Opens browser to LiteLLM setup docs, prompts user to paste their API key.
6
6
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
7
7
  */
8
- export declare function loginLiteLLM(options: OAuthController): Promise<string>;
8
+ export declare const loginLiteLLM: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
9
9
  export declare const litellmProvider: {
10
10
  readonly id: "litellm";
11
11
  readonly name: "LiteLLM";
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
2
- export declare function loginLlamaCpp(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
+ export declare const loginLlamaCpp: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
3
3
  export declare const llamaCppProvider: {
4
4
  readonly id: "llama.cpp";
5
5
  readonly name: "llama.cpp (Local OpenAI-compatible)";
@@ -1,6 +1,6 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  export declare const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
3
- export declare function loginLmStudio(options: OAuthController): Promise<string>;
3
+ export declare const loginLmStudio: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
4
4
  export declare const lmStudioProvider: {
5
5
  readonly id: "lm-studio";
6
6
  readonly name: "LM Studio (Local OpenAI-compatible)";
@@ -12,4 +12,5 @@ export declare function pollCursorAuth(uuid: string, verifier: string): Promise<
12
12
  }>;
13
13
  export declare function loginCursor(onAuthUrl: (url: string) => void, onPollStart?: () => void): Promise<OAuthCredentials>;
14
14
  export declare function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials>;
15
+ export declare function extractCursorAccessTokenUserId(accessToken: string): string | undefined;
15
16
  export declare function isCursorTokenExpiringSoon(token: string, thresholdSeconds?: number): boolean;
@@ -6,6 +6,10 @@ export type * from "./types.js";
6
6
  * Register a custom OAuth provider.
7
7
  */
8
8
  export declare function registerOAuthProvider(provider: OAuthProviderInterface): void;
9
+ /**
10
+ * Remove a custom OAuth provider by ID.
11
+ */
12
+ export declare function unregisterOAuthProvider(id: string): void;
9
13
  /**
10
14
  * Get a custom OAuth provider by ID.
11
15
  */
@@ -1,11 +1,11 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  /**
3
3
  * Login to Parallel.
4
4
  *
5
5
  * Opens browser to the API keys page, prompts the user to paste their API key,
6
6
  * and returns the API key directly.
7
7
  */
8
- export declare function loginParallel(options: OAuthController): Promise<string>;
8
+ export declare const loginParallel: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
9
9
  export declare const parallelProvider: {
10
10
  readonly id: "parallel";
11
11
  readonly name: "Parallel";
@@ -5,7 +5,7 @@ import type { OAuthLoginCallbacks } from "./oauth/types.js";
5
5
  * Opens browser to API keys page and prompts user to paste their API key.
6
6
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
7
7
  */
8
- export declare function loginTavily(options: OAuthLoginCallbacks): Promise<string>;
8
+ export declare const loginTavily: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
9
9
  export declare const tavilyProvider: {
10
10
  readonly id: "tavily";
11
11
  readonly name: "Tavily";
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
2
- export declare function loginVercelAiGateway(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
+ export declare const loginVercelAiGateway: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
3
3
  export declare const vercelAiGatewayProvider: {
4
4
  readonly id: "vercel-ai-gateway";
5
5
  readonly name: "Vercel AI Gateway";
@@ -1,5 +1,5 @@
1
- import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
2
- export declare function loginVllm(options: OAuthController): Promise<string>;
1
+ import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
+ export declare const loginVllm: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
3
3
  export declare const vllmProvider: {
4
4
  readonly id: "vllm";
5
5
  readonly name: "vLLM (Local OpenAI-compatible)";
@@ -649,8 +649,10 @@ export interface DeveloperMessage {
649
649
  providerPayload?: ProviderPayload;
650
650
  timestamp: number;
651
651
  }
652
+ /** How an automatic retry recovered or ultimately settled a failed attempt. */
652
653
  export type AssistantRetryRecoveryKind = "credential" | "model" | "wait" | "plain";
653
- export interface AssistantRetryRecovery {
654
+ /** Persisted presentation state for an assistant error superseded by an automatic retry saga. */
655
+ export type AssistantRetryRecovery = {
654
656
  kind: "auto-retry";
655
657
  status: "recovered";
656
658
  attempt: number;
@@ -663,7 +665,13 @@ export interface AssistantRetryRecovery {
663
665
  provider: string;
664
666
  model: string;
665
667
  };
666
- }
668
+ } | {
669
+ kind: "auto-retry";
670
+ status: "superseded";
671
+ attempt: number;
672
+ recovery: AssistantRetryRecoveryKind;
673
+ note: string;
674
+ };
667
675
  export interface ContextSnapshot {
668
676
  promptTokens: number;
669
677
  nonMessageTokens: number;
@@ -1,3 +1,4 @@
1
1
  import type { UsageProvider, UsageReport } from "../usage.js";
2
+ export declare function parseCursorIndividualUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
2
3
  export declare function parseCursorUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
3
4
  export declare const cursorUsageProvider: UsageProvider;
@@ -1,23 +1,3 @@
1
- /**
2
- * OpenAI Codex "saved rate limit reset" redemption client.
3
- *
4
- * OpenAI lets paid Codex accounts bank a usage-window reset and spend it on
5
- * demand (announced 2026-06-11). The count is surfaced on `/wham/usage` as
6
- * `rate_limit_reset_credits.available_count` (see `./openai-codex.ts`), but the
7
- * actual credit objects and the redeem action live on two dedicated routes:
8
- *
9
- * GET /wham/rate-limit-reset-credits → list redeemable credits
10
- * POST /wham/rate-limit-reset-credits/consume → spend one credit
11
- * body: { credit_id, redeem_request_id, account_id? }
12
- *
13
- * `redeem_request_id` is a client-generated idempotency key (UUID). The consume
14
- * response carries a `code`: `"reset"` on success, otherwise a business reason
15
- * (`already_redeemed`, `no_credit`, `nothing_to_reset`).
16
- *
17
- * These are thin, dependency-light functions so both the interactive session
18
- * (the `/usage reset` command + auto-redeem) and any out-of-band tooling can
19
- * share one wire contract.
20
- */
21
1
  import type { FetchImpl } from "../types.js";
22
2
  /** A single redeemable (or already-spent) saved reset. */
23
3
  export interface CodexResetCredit {
@@ -1 +1,13 @@
1
- export declare const toNumber: (value: unknown) => number | undefined;
1
+ import type { UsageStatus } from "../usage.js";
2
+ /** Milliseconds in one hour. */
3
+ export declare const HOUR_MS: number;
4
+ /** Milliseconds in one day. */
5
+ export declare const DAY_MS: number;
6
+ /** Milliseconds in one seven-day week. */
7
+ export declare const WEEK_MS: number;
8
+ /** Parses a finite positive epoch timestamp, tolerating seconds or milliseconds. */
9
+ export declare function parsePositiveTimestamp(value: unknown): number | undefined;
10
+ /** Parses an ISO timestamp into epoch milliseconds. */
11
+ export declare function parseIsoTimestamp(value: unknown): number | undefined;
12
+ /** Maps a used fraction to the standard quota status thresholds. */
13
+ export declare function usageStatus(usedFraction: number | undefined): UsageStatus;
@@ -38,11 +38,10 @@ export declare function finalizeErrorMessage(error: unknown, rawRequestDump: Raw
38
38
  * Rewrite error message for GitHub Copilot request failures.
39
39
  * Must run AFTER finalizeErrorMessage since it replaces the message entirely.
40
40
  *
41
- * 400 model-unavailable = Copilot fleet skew. A model that `/models` advertises
42
- * (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...)
43
- * flaps between 200 and 400 because only part of Copilot's fleet has it
44
- * in the integrator allowlist. After the in-request retry exhausts,
45
- * surface guidance rather than the raw error.
41
+ * 400 `model_not_supported` = Copilot fleet skew. A model that `/models`
42
+ * advertises can flap between 200 and 400 because only part of
43
+ * Copilot's fleet has it in the integrator allowlist. After the
44
+ * in-request retry exhausts, surface guidance rather than the raw error.
46
45
  * 401 = token invalid/expired → credential removal is safe, prompt re-login.
47
46
  * 403 = token valid but access denied (plan, model policy, org restriction) →
48
47
  * do NOT reuse the auth-failed string (which triggers credential removal).
@@ -1,9 +1,8 @@
1
1
  import { isCopilotTransientModelError } from "../error/flags.js";
2
2
  export { isCopilotTransientModelError };
3
3
  /**
4
- * Wrap an initial Copilot request so transient model-availability 400s
5
- * (`model_not_supported`, `model_not_available_for_integrator`) are retried a
6
- * small number of times. No-op for non-Copilot providers.
4
+ * Wrap an initial Copilot request so transient `model_not_supported` 400s are
5
+ * retried a small number of times. No-op for non-Copilot providers.
7
6
  *
8
7
  * The callback **MUST** create a fresh in-flight request each invocation — a
9
8
  * once-consumed AsyncIterable cannot be re-iterated.
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.2.10",
4
+ "version": "17.2.12",
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,10 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/omptype": "17.2.10",
42
- "@oh-my-pi/pi-catalog": "17.2.10",
43
- "@oh-my-pi/pi-utils": "17.2.10",
44
- "@oh-my-pi/pi-wire": "17.2.10"
41
+ "@oh-my-pi/omptype": "17.2.12",
42
+ "@oh-my-pi/pi-catalog": "17.2.12",
43
+ "@oh-my-pi/pi-utils": "17.2.12",
44
+ "@oh-my-pi/pi-wire": "17.2.12"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@bufbuild/protoc-gen-es": "^2.12.1",
@@ -1374,11 +1374,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1374
1374
  try {
1375
1375
  let hasActiveApiKey = false;
1376
1376
  const activeIdentityKeys = new Set<string>();
1377
+ const activeOAuthCredentials: AuthCredential[] = [];
1377
1378
  for (const row of activeRows) {
1378
1379
  if (row.credential.type === "api_key") {
1379
1380
  hasActiveApiKey = true;
1380
1381
  continue;
1381
1382
  }
1383
+ activeOAuthCredentials.push(row.credential);
1382
1384
  const identityKey = resolveCredentialIdentityKey(provider, row.credential);
1383
1385
  if (identityKey) activeIdentityKeys.add(identityKey);
1384
1386
  }
@@ -1393,7 +1395,22 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1393
1395
  const identityKey = resolveRowCredentialIdentityKey(provider, row);
1394
1396
  if (identityKey && activeIdentityKeys.has(identityKey)) {
1395
1397
  this.#hardDeleteStmt.run(row.id);
1398
+ continue;
1396
1399
  }
1400
+ // Exact key equality misses a tombstone whose key predates a format
1401
+ // the active row now uses (pre-org `<b>` vs `<b>|org:<o>`). An active
1402
+ // credential that WOULD have replaced this row had it still been
1403
+ // active supersedes its tombstone too, so mirror the replacement
1404
+ // matcher rather than restating a weaker rule. The one-way upgrade
1405
+ // and shared-workspace guards in matchesReplacementCredential carry
1406
+ // over, so this never over-deletes another member's or subscription's
1407
+ // row.
1408
+ const disabledCredential = deserializeCredential(row);
1409
+ if (disabledCredential === null) continue;
1410
+ const superseded = activeOAuthCredentials.some(active =>
1411
+ matchesReplacementCredential(provider, disabledCredential, identityKey, active),
1412
+ );
1413
+ if (superseded) this.#hardDeleteStmt.run(row.id);
1397
1414
  }
1398
1415
  } catch {
1399
1416
  // Best-effort cleanup; don't let it break the main operation
package/src/auth-retry.ts CHANGED
@@ -2,7 +2,7 @@ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
2
  import type { OAuthAccess } from "./auth-storage";
3
3
  import * as AIError from "./error";
4
4
  import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
5
- import { isUsageLimit } from "./error/flags";
5
+ import { isAccountPolicyError, isUsageLimit } from "./error/flags";
6
6
  import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit";
7
7
 
8
8
  /**
@@ -19,9 +19,9 @@ import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-lim
19
19
  * (invalidate/usage-limit the current credential and rotate to a sibling).
20
20
  *
21
21
  * Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
22
- * failures. 403/usage-limit failures skip refresh and may repeat step (c)
23
- * until the resolver returns `undefined`, cycles, or hits
24
- * {@link AUTH_RETRY_MAX_ATTEMPTS}.
22
+ * failures. Account-scoped policy denials, 403s, and usage-limit failures skip
23
+ * refresh and may repeat step (c) until the resolver returns `undefined`,
24
+ * cycles, or hits {@link AUTH_RETRY_MAX_ATTEMPTS}.
25
25
  */
26
26
  export interface ApiKeyResolveContext {
27
27
  /** True when the resolver should rotate to a sibling credential. */
@@ -91,6 +91,7 @@ export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
91
91
  export const AUTH_RETRY_MAX_ATTEMPTS = 64;
92
92
 
93
93
  function isDirectCredentialRotationError(error: unknown): boolean {
94
+ if (isAccountPolicyError(error)) return true;
94
95
  if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
95
96
  const status = AIError.status(error);
96
97
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
@@ -1211,6 +1211,14 @@ type OAuthCandidate = UsageCandidate<OAuthCredential>;
1211
1211
  type ApiKeyCandidate = UsageCandidate<ApiKeyCredential>;
1212
1212
  type UsageRankingResult<T extends AuthCredential> = UsageCandidate<T> & { blockedUntil: number | undefined };
1213
1213
 
1214
+ type CredentialBlockRouting = {
1215
+ providerKey: string;
1216
+ strategy: CredentialRankingStrategy | undefined;
1217
+ rankingContext: CredentialRankingContext;
1218
+ blockScope: string | undefined;
1219
+ siblingBlockScopes: readonly string[];
1220
+ };
1221
+
1214
1222
  type UsageRankedCandidate<T extends AuthCredential> = UsageCandidate<T> & {
1215
1223
  blocked: boolean;
1216
1224
  blockedUntil?: number;
@@ -4271,6 +4279,58 @@ export class AuthStorage {
4271
4279
  return sessionCredential ? { ...sessionCredential, explicit: false } : undefined;
4272
4280
  }
4273
4281
 
4282
+ #credentialBlockRouting(
4283
+ provider: string,
4284
+ credentialType: AuthCredential["type"],
4285
+ modelId: string | undefined,
4286
+ ): CredentialBlockRouting {
4287
+ const providerKey = this.#getProviderTypeKey(provider, credentialType);
4288
+ const strategy = this.#rankingStrategyResolver?.(provider);
4289
+ const rankingContext: CredentialRankingContext = { modelId };
4290
+ const blockScope = strategy?.blockScope?.(rankingContext);
4291
+ return {
4292
+ providerKey,
4293
+ strategy,
4294
+ rankingContext,
4295
+ blockScope,
4296
+ siblingBlockScopes: strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []),
4297
+ };
4298
+ }
4299
+
4300
+ #blockCredentialForRotation(
4301
+ provider: string,
4302
+ credentialType: AuthCredential["type"],
4303
+ targetIndex: number,
4304
+ blockedUntil: number,
4305
+ routing: CredentialBlockRouting,
4306
+ ): UsageLimitMarkResult {
4307
+ if (targetIndex >= 0) {
4308
+ this.#markCredentialBlocked(provider, routing.providerKey, targetIndex, blockedUntil, routing.blockScope);
4309
+ }
4310
+
4311
+ const remainingCredentials = this.#getCredentialsForProvider(provider)
4312
+ .map((credential, index) => ({ credential, index }))
4313
+ .filter(
4314
+ (entry): entry is { credential: AuthCredential; index: number } =>
4315
+ entry.credential.type === credentialType && entry.index !== targetIndex,
4316
+ );
4317
+
4318
+ let retryAtMs: number | undefined;
4319
+ for (const candidate of remainingCredentials) {
4320
+ // Sibling availability must use the same scope set selection reads, or
4321
+ // this reports a sibling as free that selection will then refuse.
4322
+ const candidateBlockedUntil = this.#getCredentialBlockedUntil(
4323
+ provider,
4324
+ routing.providerKey,
4325
+ candidate.index,
4326
+ routing.siblingBlockScopes,
4327
+ );
4328
+ if (candidateBlockedUntil === undefined) return { switched: true };
4329
+ if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
4330
+ }
4331
+ return { switched: false, retryAtMs };
4332
+ }
4333
+
4274
4334
  /**
4275
4335
  * Marks the current session's credential as temporarily blocked due to usage limits.
4276
4336
  * Uses usage reports to determine accurate reset time when available.
@@ -4313,21 +4373,17 @@ export class AuthStorage {
4313
4373
  const credentialType = sessionCredential.type;
4314
4374
  const targetCredentialId = target.id;
4315
4375
 
4316
- const providerKey = this.#getProviderTypeKey(provider, credentialType);
4317
- const strategy = this.#rankingStrategyResolver?.(provider);
4318
- const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
4319
- const blockScope = strategy?.blockScope?.(rankingContext);
4320
- const siblingBlockScopes = strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
4376
+ const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
4321
4377
  const now = Date.now();
4322
4378
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4323
4379
 
4324
- if (credentialType === "oauth" && target.credential.type === "oauth" && strategy) {
4380
+ if (credentialType === "oauth" && target.credential.type === "oauth" && routing.strategy) {
4325
4381
  const report = await raceUsageWithSignal(
4326
4382
  this.#getUsageReport(provider, target.credential, options),
4327
4383
  options?.signal,
4328
4384
  );
4329
4385
  if (report) {
4330
- const scopedLimits = this.#getScopedUsageLimits(strategy, report, rankingContext);
4386
+ const scopedLimits = this.#getScopedUsageLimits(routing.strategy, report, routing.rankingContext);
4331
4387
  if (this.#isUsageLimitReached(scopedLimits)) {
4332
4388
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
4333
4389
  if (resetAtMs && resetAtMs > blockedUntil) {
@@ -4343,32 +4399,7 @@ export class AuthStorage {
4343
4399
  const targetIndex = this.#getStoredCredentials(provider).findIndex(
4344
4400
  entry => entry.id === targetCredentialId && entry.credential.type === credentialType,
4345
4401
  );
4346
- if (targetIndex >= 0) {
4347
- this.#markCredentialBlocked(provider, providerKey, targetIndex, blockedUntil, blockScope);
4348
- }
4349
-
4350
- const remainingCredentials = this.#getCredentialsForProvider(provider)
4351
- .map((credential, index) => ({ credential, index }))
4352
- .filter(
4353
- (entry): entry is { credential: AuthCredential; index: number } =>
4354
- entry.credential.type === credentialType && entry.index !== targetIndex,
4355
- );
4356
-
4357
- let retryAtMs: number | undefined;
4358
- for (const candidate of remainingCredentials) {
4359
- // Sibling availability must use the same scope set selection reads, or
4360
- // this reports a sibling as free that selection will then refuse, most
4361
- // visibly when the sibling still carries a legacy shared block.
4362
- const candidateBlockedUntil = this.#getCredentialBlockedUntil(
4363
- provider,
4364
- providerKey,
4365
- candidate.index,
4366
- siblingBlockScopes,
4367
- );
4368
- if (candidateBlockedUntil === undefined) return { switched: true };
4369
- if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
4370
- }
4371
- return { switched: false, retryAtMs };
4402
+ return this.#blockCredentialForRotation(provider, credentialType, targetIndex, blockedUntil, routing);
4372
4403
  }
4373
4404
 
4374
4405
  #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
@@ -6078,6 +6109,8 @@ export class AuthStorage {
6078
6109
  * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
6079
6110
  * (temporary block via its own backoff — default plus server usage-report
6080
6111
  * reset; sticky left intact so the next resolve re-ranks around the block).
6112
+ * - account-scoped policy denial → temporarily block that account without
6113
+ * marking its credential suspect, then rotate through eligible siblings.
6081
6114
  * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
6082
6115
  * reload when no broker hook is wired) and block it, then drop matching
6083
6116
  * sticky state.
@@ -6114,6 +6147,17 @@ export class AuthStorage {
6114
6147
  });
6115
6148
  if (!sessionCredential) return false;
6116
6149
 
6150
+ if (AIError.isAccountPolicyError(error)) {
6151
+ const routing = this.#credentialBlockRouting(provider, sessionCredential.type, options?.modelId);
6152
+ return this.#blockCredentialForRotation(
6153
+ provider,
6154
+ sessionCredential.type,
6155
+ sessionCredential.index,
6156
+ Date.now() + AuthStorage.#defaultBackoffMs,
6157
+ routing,
6158
+ ).switched;
6159
+ }
6160
+
6117
6161
  const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
6118
6162
  // Snapshot sibling availability before mutating so a soft-deleting
6119
6163
  // suspect hook can't reindex the answer out from under us.