@oh-my-pi/pi-ai 18.8.1 → 18.8.3

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.3] - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - Added `OAuthRefreshUnavailableError`, a retryable error that `keys.getWithCredential` and `oauth.access` reject with when every usable OAuth credential failed to refresh transiently; `keys.get` resolves `undefined` instead so availability probes move on to their next candidate ([#14843](https://github.com/can1357/oh-my-pi/pull/14843) by [@H4vC](https://github.com/H4vC))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed a single transient OAuth token-refresh failure (network blip, timeout, 5xx) ending a running session, including subagents restricted to an account pool, with a non-retryable "No API key for provider" error while the stored credential was still valid; the refresh error now surfaces and the request is retried ([#14843](https://github.com/can1357/oh-my-pi/pull/14843) by [@H4vC](https://github.com/H4vC))
14
+
5
15
  ## [18.8.1] - 2026-10-07
6
16
 
7
17
  ### Added
@@ -2,7 +2,7 @@ import { type ApiKeyResolver, type ResolvedApiKey } from "../auth-retry.js";
2
2
  import type { SessionAffinity } from "./affinity.js";
3
3
  import type { CredentialPool } from "./pool.js";
4
4
  import type { CredentialSelector } from "./select.js";
5
- import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi, OAuthRequestIdentity } from "./types.js";
5
+ import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi } from "./types.js";
6
6
  /** Runtime (--api-key) and config (models.yml) key overrides plus the config-value resolver. */
7
7
  export declare class KeyOverrides {
8
8
  #private;
@@ -117,7 +117,13 @@ export declare class KeyCascade implements KeysApi {
117
117
  * credential to reach the correct host.
118
118
  */
119
119
  peek(provider: string): Promise<string | undefined>;
120
- /** Resolve a bearer together with the stored row that supplied it. */
120
+ /**
121
+ * Resolve a bearer together with the stored row that supplied it, for a request.
122
+ * Same precedence as {@link KeyCascade.get}, but rejects with
123
+ * {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
124
+ * refresh failed with a retryable error and no other permitted source could serve
125
+ * the request, so request paths retry instead of reporting a missing key.
126
+ */
121
127
  getWithCredential(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined>;
122
128
  /**
123
129
  * Get API key for a provider.
@@ -127,8 +133,11 @@ export declare class KeyCascade implements KeysApi {
127
133
  * OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
128
134
  * can serve or a config key (a models.yml `apiKey`, often for a proxy
129
135
  * `baseUrl`) owns the provider, rather than using any other credential.
136
+ * A transient OAuth refresh failure resolves `undefined`, so availability
137
+ * probes move on to their next candidate; request paths use
138
+ * {@link KeyCascade.getWithCredential}, which surfaces it.
130
139
  */
131
- get(provider: string, sessionId?: string, options?: AuthApiKeyOptions, onCredentialId?: (id: number, identity?: OAuthRequestIdentity) => void): Promise<string | undefined>;
140
+ get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
132
141
  /**
133
142
  * Build an {@link ApiKeyResolver} backed by this storage, implementing the
134
143
  * central a/b/c auth-retry policy:
@@ -37,9 +37,11 @@ export declare class OAuthAccounts implements OAuthApi {
37
37
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
38
38
  * scenarios, prefer API-key resolution.
39
39
  *
40
- * Returns `undefined` when no OAuth credential is available, the
41
- * credential fails to refresh, or runtime/config overrides have replaced
42
- * OAuth with an explicit API key.
40
+ * Returns `undefined` when no usable OAuth credential is available
41
+ * (none stored, or every one definitively failed to refresh) or
42
+ * runtime/config overrides have replaced OAuth with an explicit API key.
43
+ * Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
44
+ * retryable) when a retryable refresh failure left no usable credential.
43
45
  */
44
46
  access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
45
47
  /**
@@ -32,6 +32,8 @@ export type TryOAuthOptions = {
32
32
  blockScopes?: readonly string[];
33
33
  /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
34
34
  allowFallback?: boolean;
35
+ /** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
36
+ onTransientRefreshFailure?: (error: unknown) => void;
35
37
  };
36
38
  /** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
37
39
  export interface CredentialSelectorDeps {
@@ -812,9 +812,20 @@ export interface KeysApi {
812
812
  * 4. API key persisted by a successful `/login`
813
813
  * 5. Environment variable
814
814
  * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
815
+ *
816
+ * Resolves `undefined` when no permitted credential can serve, including when
817
+ * OAuth refresh failed transiently (network, timeout, 5xx), so availability
818
+ * probes move on to their next candidate. Request paths use
819
+ * {@link KeysApi.getWithCredential}, which surfaces that failure.
815
820
  */
816
821
  get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
817
- /** Resolve a bearer together with its durable stored credential row id, when known. */
822
+ /**
823
+ * Resolve a bearer together with its durable stored credential row id, when known.
824
+ * Same precedence as {@link KeysApi.get}, but rejects with
825
+ * `OAuthRefreshUnavailableError` (transient, retryable) when OAuth refresh failed
826
+ * with a retryable error and no other permitted source could serve the request;
827
+ * the stored credential is kept.
828
+ */
818
829
  getWithCredential(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<ResolvedApiKey | undefined>;
819
830
  /**
820
831
  * Peek at API key for a provider without refreshing OAuth tokens.
@@ -935,9 +946,11 @@ export interface OAuthApi {
935
946
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
936
947
  * scenarios, prefer {@link AuthStorage.keys.get}.
937
948
  *
938
- * Returns `undefined` when no OAuth credential is available, the
939
- * credential fails to refresh, or runtime/config overrides have replaced
940
- * OAuth with an explicit API key.
949
+ * Returns `undefined` when no usable OAuth credential is available (none
950
+ * stored, or every one definitively failed to refresh) or runtime/config
951
+ * overrides have replaced OAuth with an explicit API key. Rejects with
952
+ * `OAuthRefreshUnavailableError` (transient, retryable) when a retryable
953
+ * refresh failure left no usable credential.
941
954
  */
942
955
  access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
943
956
  /**
@@ -10,6 +10,16 @@ export declare class MissingApiKeyError extends Error {
10
10
  readonly provider: string | undefined;
11
11
  constructor(provider?: string, message?: string);
12
12
  }
13
+ /**
14
+ * Every usable OAuth credential for a provider failed to refresh with a
15
+ * retryable error (network blip, timeout, 5xx). The stored credentials are
16
+ * intact, so this is classified transient: retrying the request can succeed,
17
+ * unlike {@link MissingApiKeyError}. The refresh failure is kept as `cause`.
18
+ */
19
+ export declare class OAuthRefreshUnavailableError extends Error {
20
+ readonly provider: string;
21
+ constructor(provider: string, cause: unknown);
22
+ }
13
23
  /** A user-facing login flow required an `onPrompt` callback that was not supplied. */
14
24
  export declare class OnPromptRequiredError extends Error {
15
25
  constructor(providerLabel: string);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.8.1",
3
+ "version": "18.8.3",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.8.1",
159
- "@oh-my-pi/pi-catalog": "18.8.1",
160
- "@oh-my-pi/pi-natives": "18.8.1",
161
- "@oh-my-pi/pi-utils": "18.8.1",
162
- "@oh-my-pi/pi-wire": "18.8.1"
158
+ "@oh-my-pi/omptype": "18.8.3",
159
+ "@oh-my-pi/pi-catalog": "18.8.3",
160
+ "@oh-my-pi/pi-natives": "18.8.3",
161
+ "@oh-my-pi/pi-utils": "18.8.3",
162
+ "@oh-my-pi/pi-wire": "18.8.3"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -1,5 +1,5 @@
1
1
  import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
2
- import { $env, $envExact } from "@oh-my-pi/pi-utils";
2
+ import { $env, $envExact, logger } from "@oh-my-pi/pi-utils";
3
3
  import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
4
4
  import { getEnvApiKey, getEnvApiKeyName } from "../env-api-key";
5
5
  import * as AIError from "../error";
@@ -7,7 +7,7 @@ import { isUsageLimitOutcome } from "../error/rate-limit";
7
7
  import { AUTHENTICATED_SENTINEL } from "../registry/types";
8
8
  import type { SessionAffinity } from "./affinity";
9
9
  import type { CredentialPool } from "./pool";
10
- import type { CredentialSelector } from "./select";
10
+ import type { CredentialSelector, OAuthResolutionResult } from "./select";
11
11
  import type {
12
12
  AuthApiKeyOptions,
13
13
  AuthCredential,
@@ -308,7 +308,13 @@ export class KeyCascade implements KeysApi {
308
308
  return undefined;
309
309
  }
310
310
 
311
- /** Resolve a bearer together with the stored row that supplied it. */
311
+ /**
312
+ * Resolve a bearer together with the stored row that supplied it, for a request.
313
+ * Same precedence as {@link KeyCascade.get}, but rejects with
314
+ * {@link AIError.OAuthRefreshUnavailableError} (transient, retryable) when OAuth
315
+ * refresh failed with a retryable error and no other permitted source could serve
316
+ * the request, so request paths retry instead of reporting a missing key.
317
+ */
312
318
  async getWithCredential(
313
319
  provider: string,
314
320
  sessionId?: string,
@@ -316,7 +322,7 @@ export class KeyCascade implements KeysApi {
316
322
  ): Promise<ResolvedApiKey | undefined> {
317
323
  let credentialId: number | undefined;
318
324
  let oauthIdentity: OAuthRequestIdentity | undefined;
319
- const apiKey = await this.get(provider, sessionId, options, (id, identity) => {
325
+ const apiKey = await this.#resolve(provider, sessionId, options, (id, identity) => {
320
326
  credentialId = id;
321
327
  oauthIdentity = identity;
322
328
  });
@@ -331,8 +337,21 @@ export class KeyCascade implements KeysApi {
331
337
  * OAuth accounts and throws {@link AIError.MissingApiKeyError} when none
332
338
  * can serve or a config key (a models.yml `apiKey`, often for a proxy
333
339
  * `baseUrl`) owns the provider, rather than using any other credential.
340
+ * A transient OAuth refresh failure resolves `undefined`, so availability
341
+ * probes move on to their next candidate; request paths use
342
+ * {@link KeyCascade.getWithCredential}, which surfaces it.
334
343
  */
335
- async get(
344
+ async get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
345
+ try {
346
+ return await this.#resolve(provider, sessionId, options);
347
+ } catch (error) {
348
+ if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
349
+ logger.debug("API key probe skipped transient OAuth refresh failure", { provider, error: error.message });
350
+ return undefined;
351
+ }
352
+ }
353
+
354
+ async #resolve(
336
355
  provider: string,
337
356
  sessionId?: string,
338
357
  options?: AuthApiKeyOptions,
@@ -363,7 +382,16 @@ export class KeyCascade implements KeysApi {
363
382
 
364
383
  // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
365
384
  // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
366
- const oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
385
+ // A transient OAuth refresh failure still lets later sources serve the request; it is
386
+ // rethrown only when none can, so callers retry instead of reporting a missing key.
387
+ let oauthResolved: OAuthResolutionResult | undefined;
388
+ let oauthRefreshFailure: AIError.OAuthRefreshUnavailableError | undefined;
389
+ try {
390
+ oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
391
+ } catch (error) {
392
+ if (!(error instanceof AIError.OAuthRefreshUnavailableError)) throw error;
393
+ oauthRefreshFailure = error;
394
+ }
367
395
  if (oauthResolved) {
368
396
  if (onCredentialId && oauthResolved.credentialId !== undefined) {
369
397
  const { orgId, region, inferenceRegion } = oauthResolved.credential;
@@ -372,6 +400,7 @@ export class KeyCascade implements KeysApi {
372
400
  return oauthResolved.apiKey;
373
401
  }
374
402
  if (restricted) {
403
+ if (oauthRefreshFailure) throw oauthRefreshFailure;
375
404
  throw new AIError.MissingApiKeyError(
376
405
  provider,
377
406
  `No API key for provider: ${provider} (session ${sessionId} is restricted to its OAuth account pool and none of those accounts is available)`,
@@ -420,6 +449,7 @@ export class KeyCascade implements KeysApi {
420
449
  if (apiKey !== undefined && credentialId !== undefined) onCredentialId?.(credentialId);
421
450
  return apiKey;
422
451
  }
452
+ if (oauthRefreshFailure) throw oauthRefreshFailure;
423
453
  return undefined;
424
454
  }
425
455
 
package/src/auth/oauth.ts CHANGED
@@ -127,9 +127,11 @@ export class OAuthAccounts implements OAuthApi {
127
127
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
128
128
  * scenarios, prefer API-key resolution.
129
129
  *
130
- * Returns `undefined` when no OAuth credential is available, the
131
- * credential fails to refresh, or runtime/config overrides have replaced
132
- * OAuth with an explicit API key.
130
+ * Returns `undefined` when no usable OAuth credential is available
131
+ * (none stored, or every one definitively failed to refresh) or
132
+ * runtime/config overrides have replaced OAuth with an explicit API key.
133
+ * Rejects with {@link AIError.OAuthRefreshUnavailableError} (transient,
134
+ * retryable) when a retryable refresh failure left no usable credential.
133
135
  */
134
136
  async access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined> {
135
137
  // Runtime / config overrides intentionally short-circuit OAuth: when the
@@ -65,6 +65,8 @@ export type TryOAuthOptions = {
65
65
  blockScopes?: readonly string[];
66
66
  /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
67
67
  allowFallback?: boolean;
68
+ /** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
69
+ onTransientRefreshFailure?: (error: unknown) => void;
68
70
  };
69
71
 
70
72
  /** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
@@ -653,6 +655,14 @@ export class CredentialSelector {
653
655
  : { selection, usage: null, usageChecked: false },
654
656
  );
655
657
  const preflightFailures = new Set<OAuthCandidate>();
658
+ // The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
659
+ // When no candidate resolves, it is rethrown so callers retry instead of reporting
660
+ // a missing key. Refresher outcomes after a dead grant (row disabled, CAS lost) are
661
+ // classified auth failures and keep resolving to undefined.
662
+ let transientRefreshFailure: unknown;
663
+ const recordTransientRefreshFailure = (error: unknown): void => {
664
+ if (AIError.retriable(AIError.classify(error))) transientRefreshFailure = error;
665
+ };
656
666
 
657
667
  const sessionPreferredCandidate = candidates.findIndex(
658
668
  candidate =>
@@ -799,16 +809,21 @@ export class CredentialSelector {
799
809
  // to a sibling account with the wrong prefetched usage/plan.
800
810
  return;
801
811
  }
802
- } else if (credentialId !== undefined) {
803
- const latestIndex = this.#deps.pool.entries(provider).findIndex(entry => entry.id === credentialId);
804
- if (latestIndex !== -1) {
805
- this.#deps.blocks.mark(
806
- provider,
807
- providerKey,
808
- latestIndex,
809
- Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
810
- AUTH_BLOCK_SCOPE,
811
- );
812
+ } else {
813
+ recordTransientRefreshFailure(error);
814
+ if (credentialId !== undefined) {
815
+ const latestIndex = this.#deps.pool
816
+ .entries(provider)
817
+ .findIndex(entry => entry.id === credentialId);
818
+ if (latestIndex !== -1) {
819
+ this.#deps.blocks.mark(
820
+ provider,
821
+ providerKey,
822
+ latestIndex,
823
+ Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
824
+ AUTH_BLOCK_SCOPE,
825
+ );
826
+ }
812
827
  }
813
828
  }
814
829
  preflightFailures.add(candidate);
@@ -882,11 +897,15 @@ export class CredentialSelector {
882
897
  rankingContext,
883
898
  blockScope,
884
899
  blockScopes,
900
+ onTransientRefreshFailure: recordTransientRefreshFailure,
885
901
  });
886
902
  if (resolved) return resolved;
887
903
  }
888
904
  }
889
905
 
906
+ if (transientRefreshFailure !== undefined) {
907
+ throw new AIError.OAuthRefreshUnavailableError(provider, transientRefreshFailure);
908
+ }
890
909
  return undefined;
891
910
  }
892
911
 
@@ -1117,6 +1136,7 @@ export class CredentialSelector {
1117
1136
  if (allowFallback) return this.resolveOAuth(provider, sessionId, options);
1118
1137
  }
1119
1138
  } else {
1139
+ usageOptions.onTransientRefreshFailure?.(error);
1120
1140
  // Block temporarily for transient failures (5 minutes)
1121
1141
  this.#deps.blocks.mark(
1122
1142
  provider,
package/src/auth/types.ts CHANGED
@@ -894,9 +894,20 @@ export interface KeysApi {
894
894
  * 4. API key persisted by a successful `/login`
895
895
  * 5. Environment variable
896
896
  * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
897
+ *
898
+ * Resolves `undefined` when no permitted credential can serve, including when
899
+ * OAuth refresh failed transiently (network, timeout, 5xx), so availability
900
+ * probes move on to their next candidate. Request paths use
901
+ * {@link KeysApi.getWithCredential}, which surfaces that failure.
897
902
  */
898
903
  get(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
899
- /** Resolve a bearer together with its durable stored credential row id, when known. */
904
+ /**
905
+ * Resolve a bearer together with its durable stored credential row id, when known.
906
+ * Same precedence as {@link KeysApi.get}, but rejects with
907
+ * `OAuthRefreshUnavailableError` (transient, retryable) when OAuth refresh failed
908
+ * with a retryable error and no other permitted source could serve the request;
909
+ * the stored credential is kept.
910
+ */
900
911
  getWithCredential(
901
912
  provider: string,
902
913
  sessionId?: string,
@@ -1017,9 +1028,11 @@ export interface OAuthApi {
1017
1028
  * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
1018
1029
  * scenarios, prefer {@link AuthStorage.keys.get}.
1019
1030
  *
1020
- * Returns `undefined` when no OAuth credential is available, the
1021
- * credential fails to refresh, or runtime/config overrides have replaced
1022
- * OAuth with an explicit API key.
1031
+ * Returns `undefined` when no usable OAuth credential is available (none
1032
+ * stored, or every one definitively failed to refresh) or runtime/config
1033
+ * overrides have replaced OAuth with an explicit API key. Rejects with
1034
+ * `OAuthRefreshUnavailableError` (transient, retryable) when a retryable
1035
+ * refresh failure left no usable credential.
1023
1036
  */
1024
1037
  access(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
1025
1038
  /**
package/src/error/auth.ts CHANGED
@@ -19,6 +19,26 @@ export class MissingApiKeyError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ /**
23
+ * Every usable OAuth credential for a provider failed to refresh with a
24
+ * retryable error (network blip, timeout, 5xx). The stored credentials are
25
+ * intact, so this is classified transient: retrying the request can succeed,
26
+ * unlike {@link MissingApiKeyError}. The refresh failure is kept as `cause`.
27
+ */
28
+ export class OAuthRefreshUnavailableError extends Error {
29
+ readonly provider: string;
30
+
31
+ constructor(provider: string, cause: unknown) {
32
+ super(
33
+ `OAuth token refresh failed for provider ${provider}: ${cause instanceof Error ? cause.message : String(cause)}`,
34
+ { cause },
35
+ );
36
+ this.name = "OAuthRefreshUnavailableError";
37
+ this.provider = provider;
38
+ attach(this, create(Flag.Transient));
39
+ }
40
+ }
41
+
22
42
  /** A user-facing login flow required an `onPrompt` callback that was not supplied. */
23
43
  export class OnPromptRequiredError extends Error {
24
44
  constructor(providerLabel: string) {