@gajae-code/ai 0.12.1 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.2] - 2026-07-30
6
+
5
7
  ## [0.12.1] - 2026-07-29
6
8
 
7
9
  ### Fixed
@@ -19,6 +21,7 @@
19
21
  - The Anthropic "thinking blocks in the latest assistant message cannot be modified" 400 now escalates its one-shot replay repair. The error names the latest assistant message but its cited `messages.N.content.M` path can point at an earlier replayed turn, so the latest-only repair was rejected identically and killed the turn; recovery now retries once more with thinking dropped from every replayed assistant message.
20
22
  - Anthropic adaptive-thinking `display` support is now decided by the canonical model-version parser instead of a provider-local `claude-opus-(\d+)-(\d+)` regex. The regex only matched two-component ids, so a single-component alias such as `claude-opus-5` was classified as pre-4.7 while its dated snapshot `claude-opus-5-20260101` was not: the alias sent `thinking: { type: "adaptive" }` without `display: "summarized"`, additionally requested the `interleaved-thinking-2025-05-14` beta, and had its returned thinking blocks recorded as raw rather than summarized. Both Anthropic and Bedrock providers now share `supportsAnthropicAdaptiveThinkingDisplay`, so alias and dated ids of the same model send an identical request shape.
21
23
  - Anthropic requests that force a tool choice no longer replay signed thinking blocks. Forcing `tool_choice` strips `thinking` from the request (the API rejects the combination), but the converted history still carried native `thinking`/`redacted_thinking` blocks from thinking-enabled turns, so eager tool-forcing turns (e.g. the todo bootstrap) sent a request whose history contradicted its own thinking setting and drew a 400. The replay now degrades in the same rebuild; the forced request trades its prompt-cache prefix for a shape the API accepts.
24
+ - A definitively failed OAuth refresh can no longer loop forever instead of disabling the credential. The refresh-failure path disables the row with a CAS conditioned on its serialized `data`, and treated a lost CAS as proof that a peer had rotated the token: it reloaded the store and re-resolved, without bound. That predicate also misses when nothing was rotated — an account switcher that replaces the provider's rows leaves the attempted id gone, and an unrelated identity-metadata write leaves the row byte-different — so a revoked credential was never disabled and every subsequent request re-issued the same `invalid_grant` refresh (observed in the wild as ~3k `OAuth token refresh failed` / `disable lost CAS` log pairs in 3.5 hours, one wasted refresh round-trip per request). When the row still holds the refresh token that just failed, it is now disabled by id (no peer rotation exists to clobber); otherwise the reload-and-retry recovery is capped, so resolution terminates instead of recursing until the runtime dies.
22
25
 
23
26
  ## [0.12.0] - 2026-07-28
24
27
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.12.1",
4
+ "version": "0.12.2",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/utils": "0.12.1",
43
+ "@gajae-code/utils": "0.12.2",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -572,6 +572,16 @@ const OAUTH_REFRESH_SKEW_MS = 60_000;
572
572
  * pathological detach-without-reattach loops can't grow memory unboundedly.
573
573
  */
574
574
  const MAX_PENDING_DISABLED_EVENTS = 32;
575
+ /**
576
+ * Cap on how many times an OAuth resolution may reload the credential store and
577
+ * re-resolve after a failed refresh. Each retry exists to recover from a peer
578
+ * process rotating (or replacing) the row under us, which is a bounded event:
579
+ * the peer either published a usable credential we pick up on the next pass, or
580
+ * it did not. Without a cap, a credential whose disable can never be applied
581
+ * (row replaced by an account switcher, CAS predicate that can never match)
582
+ * makes the recovery path re-issue the same failing token refresh forever.
583
+ */
584
+ const MAX_OAUTH_RESOLUTION_RELOADS = 3;
575
585
 
576
586
  type UsageCacheEntry<T> = {
577
587
  value: T;
@@ -1426,6 +1436,34 @@ export class AuthStorage {
1426
1436
  return true;
1427
1437
  }
1428
1438
 
1439
+ /**
1440
+ * Whether the persisted row `credentialId` is still an OAuth credential holding
1441
+ * `refreshToken`. Used by the refresh-failure path to tell "a peer rotated this
1442
+ * row" (retry is worthwhile) apart from "the row is unchanged but the CAS
1443
+ * predicate cannot match it" (retry replays the same failing refresh).
1444
+ */
1445
+ #credentialRowHoldsRefreshToken(provider: string, credentialId: number, refreshToken: string): boolean {
1446
+ const row = this.#store.listAuthCredentials(provider).find(entry => entry.id === credentialId);
1447
+ const credential = row?.credential;
1448
+ return credential?.type === "oauth" && credential.refresh === refreshToken;
1449
+ }
1450
+
1451
+ /**
1452
+ * Soft-deletes a row by id, bypassing the data-equality CAS. Only safe when the
1453
+ * caller has confirmed the row still holds the credential it attempted to
1454
+ * refresh, so no peer rotation can be clobbered.
1455
+ */
1456
+ #disableCredentialById(provider: string, credentialId: number, disabledCause: string): void {
1457
+ this.#store.deleteAuthCredential(credentialId, disabledCause);
1458
+ const entries = this.#getStoredCredentials(provider);
1459
+ this.#setStoredCredentials(
1460
+ provider,
1461
+ entries.filter(entry => entry.id !== credentialId),
1462
+ );
1463
+ this.#resetProviderAssignments(provider);
1464
+ this.#emitCredentialDisabled({ provider, disabledCause });
1465
+ }
1466
+
1429
1467
  #emitCredentialDisabled(event: CredentialDisabledEvent): void {
1430
1468
  if (this.#credentialDisabledListeners.size === 0) {
1431
1469
  // No subscribers — buffer for later replay. Cap the backlog so a process that runs
@@ -2989,7 +3027,15 @@ export class AuthStorage {
2989
3027
  provider: string,
2990
3028
  sessionId?: string,
2991
3029
  options?: AuthApiKeyOptions,
3030
+ reloadsUsed = 0,
2992
3031
  ): Promise<OAuthResolutionResult | undefined> {
3032
+ if (reloadsUsed > MAX_OAUTH_RESOLUTION_RELOADS) {
3033
+ logger.warn("OAuth credential resolution exhausted its reload budget", {
3034
+ provider,
3035
+ reloadsUsed,
3036
+ });
3037
+ return undefined;
3038
+ }
2993
3039
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options);
2994
3040
  const selectedOAuthCredential =
2995
3041
  selectedCredential?.credential.type === "oauth"
@@ -3087,18 +3133,27 @@ export class AuthStorage {
3087
3133
  usagePrechecked: candidate.usageChecked,
3088
3134
  enforceProRequirement,
3089
3135
  },
3136
+ reloadsUsed,
3090
3137
  );
3091
3138
  if (resolved) return resolved;
3092
3139
  }
3093
3140
 
3094
3141
  if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
3095
- return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3096
- checkUsage,
3097
- allowBlocked: true,
3098
- prefetchedUsage: fallback.usage,
3099
- usagePrechecked: fallback.usageChecked,
3100
- enforceProRequirement,
3101
- });
3142
+ return this.#tryOAuthCredential(
3143
+ provider,
3144
+ fallback.selection,
3145
+ providerKey,
3146
+ sessionId,
3147
+ options,
3148
+ {
3149
+ checkUsage,
3150
+ allowBlocked: true,
3151
+ prefetchedUsage: fallback.usage,
3152
+ usagePrechecked: fallback.usageChecked,
3153
+ enforceProRequirement,
3154
+ },
3155
+ reloadsUsed,
3156
+ );
3102
3157
  }
3103
3158
 
3104
3159
  return undefined;
@@ -3219,6 +3274,7 @@ export class AuthStorage {
3219
3274
  usagePrechecked?: boolean;
3220
3275
  enforceProRequirement?: boolean;
3221
3276
  },
3277
+ reloadsUsed = 0,
3222
3278
  ): Promise<OAuthResolutionResult | undefined> {
3223
3279
  const {
3224
3280
  checkUsage,
@@ -3357,7 +3413,7 @@ export class AuthStorage {
3357
3413
  credentialId: attemptedCredentialId,
3358
3414
  });
3359
3415
  await this.reload();
3360
- return this.#resolveOAuthSelection(provider, sessionId, options);
3416
+ return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
3361
3417
  }
3362
3418
  }
3363
3419
  // Only remove credentials for definitive auth failures
@@ -3387,18 +3443,39 @@ export class AuthStorage {
3387
3443
  `oauth refresh failed: ${errorMsg}`,
3388
3444
  );
3389
3445
  if (!disabled) {
3390
- logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
3391
- provider,
3392
- index: selection.index,
3393
- });
3394
- await this.reload();
3395
- return this.#resolveOAuthSelection(provider, sessionId, options);
3446
+ // The CAS predicate compares the row's serialized `data`, so it also
3447
+ // misses when nothing was rotated: the row may have been replaced by
3448
+ // a peer (account switcher rewriting the provider's credentials, so
3449
+ // our snapshot's id no longer exists) or updated with unrelated
3450
+ // identity metadata. Reload-and-retry only makes progress in the
3451
+ // rotation case; otherwise the same revoked token is re-refreshed on
3452
+ // every request forever. When the row is still present with the very
3453
+ // refresh token we just tried, disabling by id is safe — there is no
3454
+ // peer rotation to clobber — so apply it directly instead of looping.
3455
+ const stillHoldsAttemptedToken =
3456
+ attemptedCredentialId !== undefined &&
3457
+ this.#credentialRowHoldsRefreshToken(provider, attemptedCredentialId, selection.credential.refresh);
3458
+ if (stillHoldsAttemptedToken && attemptedCredentialId !== undefined) {
3459
+ logger.warn("OAuth refresh disable CAS mismatched an unrotated row; disabling by id", {
3460
+ provider,
3461
+ index: selection.index,
3462
+ credentialId: attemptedCredentialId,
3463
+ });
3464
+ this.#disableCredentialById(provider, attemptedCredentialId, `oauth refresh failed: ${errorMsg}`);
3465
+ } else {
3466
+ logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
3467
+ provider,
3468
+ index: selection.index,
3469
+ });
3470
+ await this.reload();
3471
+ return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
3472
+ }
3396
3473
  }
3397
3474
  if (
3398
3475
  !this.#getCredentialSelector(provider, options) &&
3399
3476
  this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")
3400
3477
  ) {
3401
- return this.#resolveOAuthSelection(provider, sessionId, options);
3478
+ return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed);
3402
3479
  }
3403
3480
  } else {
3404
3481
  // Block temporarily for transient failures (5 minutes)