@oh-my-pi/pi-ai 17.1.2 → 17.1.4

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 (54) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/types/auth-broker/client.d.ts +7 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +4 -1
  5. package/dist/types/auth-broker/types.d.ts +6 -1
  6. package/dist/types/auth-broker/wire-schemas.d.ts +41 -0
  7. package/dist/types/auth-storage.d.ts +55 -0
  8. package/dist/types/providers/anthropic.d.ts +1 -7
  9. package/dist/types/providers/claude-code-fingerprint.d.ts +15 -0
  10. package/dist/types/providers/cursor.d.ts +30 -7
  11. package/dist/types/providers/openai-codex/request-transformer.d.ts +5 -4
  12. package/dist/types/providers/openai-codex-responses.d.ts +3 -0
  13. package/dist/types/registry/alibaba-token-plan.d.ts +11 -0
  14. package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
  15. package/dist/types/registry/oauth/anthropic.d.ts +1 -0
  16. package/dist/types/registry/oauth/index.d.ts +1 -0
  17. package/dist/types/registry/oauth/types.d.ts +8 -0
  18. package/dist/types/types.d.ts +71 -1
  19. package/dist/types/usage/minimax-code.d.ts +1 -0
  20. package/dist/types/usage.d.ts +10 -0
  21. package/dist/types/utils/idle-iterator.d.ts +6 -4
  22. package/package.json +4 -4
  23. package/src/auth-broker/client.ts +24 -1
  24. package/src/auth-broker/remote-store.ts +12 -0
  25. package/src/auth-broker/server.ts +7 -0
  26. package/src/auth-broker/types.ts +7 -0
  27. package/src/auth-broker/wire-schemas.ts +23 -0
  28. package/src/auth-storage.ts +279 -29
  29. package/src/error/flags.ts +1 -1
  30. package/src/providers/__tests__/kimi-code-thinking.test.ts +40 -5
  31. package/src/providers/amazon-bedrock.ts +3 -4
  32. package/src/providers/anthropic.ts +98 -32
  33. package/src/providers/azure-openai-responses.ts +36 -5
  34. package/src/providers/claude-code-fingerprint.ts +19 -0
  35. package/src/providers/cursor.ts +525 -40
  36. package/src/providers/openai-codex/request-transformer.ts +27 -7
  37. package/src/providers/openai-codex-responses.ts +37 -14
  38. package/src/providers/openai-completions.ts +2 -1
  39. package/src/providers/openai-responses.ts +16 -9
  40. package/src/providers/openai-shared.ts +12 -1
  41. package/src/registry/alibaba-token-plan.ts +103 -21
  42. package/src/registry/oauth/anthropic-constants.ts +12 -0
  43. package/src/registry/oauth/anthropic.ts +3 -1
  44. package/src/registry/oauth/index.ts +1 -0
  45. package/src/registry/oauth/types.ts +8 -0
  46. package/src/stream.ts +7 -2
  47. package/src/types.ts +79 -1
  48. package/src/usage/alibaba-token-plan.ts +34 -6
  49. package/src/usage/claude.ts +7 -2
  50. package/src/usage/minimax-code.ts +280 -19
  51. package/src/usage/openai-codex.ts +61 -14
  52. package/src/usage.ts +10 -0
  53. package/src/utils/idle-iterator.ts +8 -6
  54. package/src/utils.ts +5 -5
@@ -54,6 +54,7 @@ import { googleGeminiCliUsageProvider } from "./usage/gemini";
54
54
  import { githubCopilotUsageProvider } from "./usage/github-copilot";
55
55
  import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
56
56
  import { kimiUsageProvider } from "./usage/kimi";
57
+ import { minimaxCodeUsageProvider } from "./usage/minimax-code";
57
58
  import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama";
58
59
  import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
59
60
  import {
@@ -170,6 +171,28 @@ export interface StoredCredentialBlock {
170
171
  updatedAtMs?: number;
171
172
  }
172
173
 
174
+ /**
175
+ * Identity slice of a disabled (soft-deleted) credential tombstone — cause and
176
+ * account identity only, never token material. Surfaced so auto-disabled
177
+ * accounts (e.g. an expired Anthropic OAuth grant) stay visible in `omp usage`
178
+ * instead of silently vanishing until the user notices missing quota.
179
+ */
180
+ export interface DisabledCredentialSummary {
181
+ /** Database row id (matches {@link StoredAuthCredential.id}). */
182
+ id: number;
183
+ provider: string;
184
+ type: AuthCredential["type"];
185
+ email?: string;
186
+ accountId?: string;
187
+ /** Organization/workspace the credential was scoped to (Anthropic/ChatGPT multi-subscription). */
188
+ orgId?: string;
189
+ orgName?: string;
190
+ /** Verbatim disable cause captured when the row was torn down. */
191
+ cause: string;
192
+ /** Epoch ms the row was disabled (SQLite `updated_at`), when known. */
193
+ disabledAtMs?: number;
194
+ }
195
+
173
196
  /**
174
197
  * Per-credential health record returned by {@link AuthStorage.checkCredentials}.
175
198
  *
@@ -353,6 +376,21 @@ export interface AuthCredentialStore {
353
376
  /** Optional hook to notify the underlying store that usage report cache is stale. */
354
377
  invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
355
378
  listAuthCredentials(provider?: string): StoredAuthCredential[];
379
+ /**
380
+ * Optional store hook to re-hydrate the credential snapshot from its
381
+ * backing source. Remote broker stores re-fetch `GET /v1/snapshot` so a
382
+ * disk-cached snapshot (up to an hour stale) cannot be paired with live
383
+ * per-credential data; local SQLite stores omit it — their reads are
384
+ * always current.
385
+ */
386
+ refreshSnapshot?(): Promise<unknown>;
387
+ /**
388
+ * Disabled credential tombstones (see {@link DisabledCredentialSummary}).
389
+ * Optional: remote stores forward to the broker's
390
+ * `GET /v1/credentials/disabled` (empty list when the broker predates the
391
+ * endpoint); stores without tombstones omit it.
392
+ */
393
+ listDisabledCredentials?(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]>;
356
394
  updateAuthCredential(id: number, credential: AuthCredential): void;
357
395
  deleteAuthCredential(id: number, disabledCause: string): void;
358
396
  tryDisableAuthCredentialIfMatches(
@@ -381,6 +419,8 @@ export interface AuthCredentialStore {
381
419
  getCredentialBlockReconcileAfter?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
382
420
  /** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */
383
421
  upsertCredentialBlock?(block: StoredCredentialBlock): void;
422
+ /** Drop one block row for a credential/provider/scope key. */
423
+ deleteCredentialBlock?(credentialId: number, providerKey: string, blockScope: string): void;
384
424
  /** Drop every block row for a credential (all providerKeys/scopes). */
385
425
  deleteCredentialBlocks?(credentialId: number): void;
386
426
  /** Prune rows with blocked_until_ms <= nowMs. */
@@ -606,6 +646,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
606
646
  alibabaTokenPlanUsageProvider,
607
647
  openaiCodexUsageProvider,
608
648
  kimiUsageProvider,
649
+ minimaxCodeUsageProvider,
609
650
  antigravityUsageProvider,
610
651
  googleGeminiCliUsageProvider,
611
652
  ollamaUsageProvider,
@@ -1654,11 +1695,16 @@ export class AuthStorage {
1654
1695
  provider: string,
1655
1696
  providerKey: string,
1656
1697
  credentialIndex: number,
1657
- blockScope: string | undefined = undefined,
1698
+ blockScopeOrScopes: string | readonly string[] | undefined = undefined,
1658
1699
  ): number | undefined {
1659
1700
  const nowMs = Date.now();
1701
+ // A request honours its own scope plus any legacy catch-all scope, so a
1702
+ // block written before backoff was scoped still applies to everything.
1703
+ const scopes = (
1704
+ typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])
1705
+ ).filter(scope => scope.length > 0);
1660
1706
  let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialIndex, nowMs);
1661
- if (blockScope) {
1707
+ for (const blockScope of scopes) {
1662
1708
  const scopedBlockedUntil = this.#getCredentialBlockedUntilForKey(
1663
1709
  this.#toScopedBackoffKey(providerKey, blockScope),
1664
1710
  credentialIndex,
@@ -1671,16 +1717,14 @@ export class AuthStorage {
1671
1717
 
1672
1718
  const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
1673
1719
  if (credentialId === undefined) return blockedUntil;
1674
- if (!blockScope || provider !== "openai-codex") {
1675
- const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
1676
- if (
1677
- persistedGlobalBlockedUntil !== undefined &&
1678
- (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
1679
- ) {
1680
- blockedUntil = persistedGlobalBlockedUntil;
1681
- }
1720
+ const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
1721
+ if (
1722
+ persistedGlobalBlockedUntil !== undefined &&
1723
+ (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
1724
+ ) {
1725
+ blockedUntil = persistedGlobalBlockedUntil;
1682
1726
  }
1683
- if (blockScope) {
1727
+ for (const blockScope of scopes) {
1684
1728
  const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, blockScope);
1685
1729
  if (
1686
1730
  persistedScopedBlockedUntil !== undefined &&
@@ -1697,7 +1741,7 @@ export class AuthStorage {
1697
1741
  provider: string,
1698
1742
  providerKey: string,
1699
1743
  credentialIndex: number,
1700
- blockScope: string | undefined = undefined,
1744
+ blockScope: string | readonly string[] | undefined = undefined,
1701
1745
  ): boolean {
1702
1746
  return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope) !== undefined;
1703
1747
  }
@@ -1882,6 +1926,8 @@ export class AuthStorage {
1882
1926
  strategy: CredentialRankingStrategy;
1883
1927
  rankingContext: CredentialRankingContext;
1884
1928
  blockScope?: string;
1929
+ /** Scopes a block may live under for this request; reads honour all of them. */
1930
+ blockScopes?: readonly string[];
1885
1931
  }): Promise<ApiKeyCandidate[]> {
1886
1932
  const nowMs = Date.now();
1887
1933
  const { strategy } = args;
@@ -1895,7 +1941,7 @@ export class AuthStorage {
1895
1941
  args.provider,
1896
1942
  args.providerKey,
1897
1943
  selection.index,
1898
- args.blockScope,
1944
+ args.blockScopes ?? args.blockScope,
1899
1945
  );
1900
1946
  if (blockedUntil !== undefined) {
1901
1947
  return { selection, usage: null, usageChecked: false, blockedUntil };
@@ -1920,7 +1966,7 @@ export class AuthStorage {
1920
1966
  args.provider,
1921
1967
  args.providerKey,
1922
1968
  selection.index,
1923
- args.blockScope,
1969
+ args.blockScopes ?? args.blockScope,
1924
1970
  );
1925
1971
  return { selection, usage: null, usageChecked: false, blockedUntil };
1926
1972
  });
@@ -2003,6 +2049,7 @@ export class AuthStorage {
2003
2049
 
2004
2050
  const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
2005
2051
  const blockScope = strategy.blockScope?.(rankingContext);
2052
+ const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
2006
2053
  const candidates = await this.#rankApiKeySelections({
2007
2054
  providerKey,
2008
2055
  provider,
@@ -2012,6 +2059,7 @@ export class AuthStorage {
2012
2059
  strategy,
2013
2060
  rankingContext,
2014
2061
  blockScope,
2062
+ blockScopes,
2015
2063
  });
2016
2064
  return candidates[0]?.selection ?? fallback;
2017
2065
  }
@@ -2708,7 +2756,10 @@ export class AuthStorage {
2708
2756
  this.#resetProviderAssignments(provider);
2709
2757
  return { type: "api_key" };
2710
2758
  }
2711
- const newCredential: OAuthCredential = { type: "oauth", ...result };
2759
+ // Stamp the interactive-login instant: providers with an absolute grant
2760
+ // lifetime (Anthropic) need it to surface re-login deadlines, and token
2761
+ // refreshes only ever merge over this credential without clearing it.
2762
+ const newCredential: OAuthCredential = { type: "oauth", ...result, authorizedAt: Date.now() };
2712
2763
  // Use #upsertOAuthCredential to upsert the new credential.
2713
2764
  // Any legacy api_key rows from older versions will be cleaned up so they do not
2714
2765
  // shadow the new OAuth row, while preserving other active OAuth credentials.
@@ -2927,6 +2978,9 @@ export class AuthStorage {
2927
2978
  apiEndpoint: next.apiEndpoint,
2928
2979
  orgId: next.orgId ?? entry.credential.orgId,
2929
2980
  orgName: next.orgName ?? entry.credential.orgName,
2981
+ // Not part of UsageCredential — carried from the stored row so the
2982
+ // interactive-login anchor survives usage-path refresh persists.
2983
+ authorizedAt: entry.credential.authorizedAt,
2930
2984
  });
2931
2985
  }
2932
2986
 
@@ -3684,6 +3738,7 @@ export class AuthStorage {
3684
3738
 
3685
3739
  const rankingContext: CredentialRankingContext = { modelId: options.modelId };
3686
3740
  const blockScope = strategy.blockScope?.(rankingContext);
3741
+ const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
3687
3742
  const reserveFraction = Number.isFinite(options.reserveFraction)
3688
3743
  ? Math.max(0, Math.min(1, options.reserveFraction))
3689
3744
  : 0;
@@ -3692,7 +3747,7 @@ export class AuthStorage {
3692
3747
  pool.map(async ({ entry, index }): Promise<ModelUsageAccountHealth> => {
3693
3748
  const credentialType = entry.credential.type;
3694
3749
  const providerKey = this.#getProviderTypeKey(provider, credentialType);
3695
- let blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScope);
3750
+ let blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
3696
3751
  if (blockedUntil !== undefined && provider !== "openai-codex") {
3697
3752
  return {
3698
3753
  credentialId: entry.id,
@@ -3718,7 +3773,7 @@ export class AuthStorage {
3718
3773
  }
3719
3774
 
3720
3775
  if (provider === "openai-codex") {
3721
- blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScope);
3776
+ blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
3722
3777
  }
3723
3778
  if (blockedUntil !== undefined) {
3724
3779
  return {
@@ -4145,6 +4200,7 @@ export class AuthStorage {
4145
4200
  const strategy = this.#rankingStrategyResolver?.(provider);
4146
4201
  const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
4147
4202
  const blockScope = strategy?.blockScope?.(rankingContext);
4203
+ const siblingBlockScopes = strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
4148
4204
  const now = Date.now();
4149
4205
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4150
4206
 
@@ -4179,11 +4235,14 @@ export class AuthStorage {
4179
4235
 
4180
4236
  let retryAtMs: number | undefined;
4181
4237
  for (const candidate of remainingCredentials) {
4238
+ // Sibling availability must use the same scope set selection reads, or
4239
+ // this reports a sibling as free that selection will then refuse, most
4240
+ // visibly when the sibling still carries a legacy shared block.
4182
4241
  const candidateBlockedUntil = this.#getCredentialBlockedUntil(
4183
4242
  provider,
4184
4243
  providerKey,
4185
4244
  candidate.index,
4186
- blockScope,
4245
+ siblingBlockScopes,
4187
4246
  );
4188
4247
  if (candidateBlockedUntil === undefined) return { switched: true };
4189
4248
  if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
@@ -4305,6 +4364,8 @@ export class AuthStorage {
4305
4364
  strategy: CredentialRankingStrategy;
4306
4365
  rankingContext: CredentialRankingContext;
4307
4366
  blockScope?: string;
4367
+ /** Scopes a block may live under for this request; reads honour all of them. */
4368
+ blockScopes?: readonly string[];
4308
4369
  }): Promise<OAuthCandidate[]> {
4309
4370
  const nowMs = Date.now();
4310
4371
  const { strategy } = args;
@@ -4322,7 +4383,7 @@ export class AuthStorage {
4322
4383
  args.provider,
4323
4384
  args.providerKey,
4324
4385
  selection.index,
4325
- args.blockScope,
4386
+ args.blockScopes ?? args.blockScope,
4326
4387
  );
4327
4388
  let usage: UsageReport | null = null;
4328
4389
  let usageChecked = false;
@@ -4336,7 +4397,7 @@ export class AuthStorage {
4336
4397
  args.provider,
4337
4398
  args.providerKey,
4338
4399
  selection.index,
4339
- args.blockScope,
4400
+ args.blockScopes ?? args.blockScope,
4340
4401
  );
4341
4402
  }
4342
4403
  if (blockedUntil !== undefined) return { selection, usage, usageChecked, blockedUntil };
@@ -4447,6 +4508,8 @@ export class AuthStorage {
4447
4508
  const strategy = this.#rankingStrategyResolver?.(provider);
4448
4509
  const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
4449
4510
  const blockScope = strategy?.blockScope?.(rankingContext);
4511
+ // Reads honour every scope that applies; the scalar above is for args that persist.
4512
+ const blockScopes = strategy?.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
4450
4513
  const planRequirement = resolveOpenAICodexPlanRequirement(provider, options?.modelId);
4451
4514
  const hasPlanRequirement = planRequirement !== "none";
4452
4515
  const checkUsage = strategy !== undefined && (credentials.length > 1 || hasPlanRequirement);
@@ -4475,7 +4538,7 @@ export class AuthStorage {
4475
4538
  const sessionPreferredIsAvailable =
4476
4539
  sessionPreferredIndex !== undefined &&
4477
4540
  sessionPreferredCanRefreshOrUse &&
4478
- !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
4541
+ !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScopes);
4479
4542
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || !sessionPreferredIsWarm || hasPlanRequirement);
4480
4543
  // When ranking, seed the pinned credential first in the evaluation order so it wins genuine
4481
4544
  // ties (the ranked comparator falls back to `orderPos`) without overriding a strictly-better
@@ -4504,6 +4567,7 @@ export class AuthStorage {
4504
4567
  strategy: strategy!,
4505
4568
  rankingContext,
4506
4569
  blockScope,
4570
+ blockScopes,
4507
4571
  })
4508
4572
  : order
4509
4573
  .map(idx => credentials[idx])
@@ -4516,7 +4580,7 @@ export class AuthStorage {
4516
4580
  if (!shouldRank && sessionPreferredIndex !== undefined && !hasPlanRequirement) {
4517
4581
  const sessionPreferredCandidate = candidates.findIndex(
4518
4582
  candidate =>
4519
- !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
4583
+ !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScopes) &&
4520
4584
  candidate.selection.index === sessionPreferredIndex,
4521
4585
  );
4522
4586
  if (sessionPreferredCandidate > 0) {
@@ -4610,7 +4674,7 @@ export class AuthStorage {
4610
4674
  if (hasPlanRequirement && sessionPreferredIndex !== undefined) {
4611
4675
  const sessionPreferredCandidate = candidates.findIndex(
4612
4676
  candidate =>
4613
- !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
4677
+ !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScopes) &&
4614
4678
  candidate.selection.index === sessionPreferredIndex,
4615
4679
  );
4616
4680
  if (sessionPreferredCandidate > 0) {
@@ -4647,6 +4711,7 @@ export class AuthStorage {
4647
4711
  strategy,
4648
4712
  rankingContext,
4649
4713
  blockScope,
4714
+ blockScopes,
4650
4715
  },
4651
4716
  );
4652
4717
  if (resolved) return resolved;
@@ -4824,6 +4889,7 @@ export class AuthStorage {
4824
4889
  strategy?: CredentialRankingStrategy;
4825
4890
  rankingContext?: CredentialRankingContext;
4826
4891
  blockScope?: string;
4892
+ blockScopes?: readonly string[];
4827
4893
  /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
4828
4894
  allowFallback?: boolean;
4829
4895
  },
@@ -4838,9 +4904,13 @@ export class AuthStorage {
4838
4904
  strategy,
4839
4905
  rankingContext,
4840
4906
  blockScope,
4907
+ blockScopes,
4841
4908
  allowFallback = true,
4842
4909
  } = usageOptions;
4843
- if (!allowBlocked && this.#isCredentialBlocked(provider, providerKey, selection.index, blockScope)) {
4910
+ if (
4911
+ !allowBlocked &&
4912
+ this.#isCredentialBlocked(provider, providerKey, selection.index, blockScopes ?? blockScope)
4913
+ ) {
4844
4914
  return undefined;
4845
4915
  }
4846
4916
 
@@ -4933,6 +5003,7 @@ export class AuthStorage {
4933
5003
  apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint,
4934
5004
  orgId: result.newCredentials.orgId ?? selection.credential.orgId,
4935
5005
  orgName: result.newCredentials.orgName ?? selection.credential.orgName,
5006
+ authorizedAt: result.newCredentials.authorizedAt ?? selection.credential.authorizedAt,
4936
5007
  };
4937
5008
  if (credentialId !== undefined) {
4938
5009
  const idx = this.#replaceCredentialById(provider, credentialId, updated);
@@ -5573,6 +5644,70 @@ export class AuthStorage {
5573
5644
  * persisted and in-memory `openai-codex:oauth` blocks so credential selection
5574
5645
  * can re-include recovered seats before a stale block naturally expires.
5575
5646
  */
5647
+ /**
5648
+ * Narrow a usage report to the limits a backoff scope covers, so a scope is
5649
+ * judged only by the meters it gates. A legacy scope that meant "block
5650
+ * everything" keeps the whole report.
5651
+ */
5652
+ #scopeUsageReportToBlockScope(
5653
+ report: UsageReport,
5654
+ blockScope: string | undefined,
5655
+ strategy: CredentialRankingStrategy | undefined,
5656
+ ): UsageReport {
5657
+ if (!blockScope || !strategy?.scopeLimits || !strategy.blockScopes) return report;
5658
+ // Find a request context whose scope set leads with this scope; its scoped
5659
+ // limits are the ones this block gates.
5660
+ const modelId = blockScope === "spark" ? "gpt-5.3-codex-spark" : "gpt-5.3-codex";
5661
+ if (strategy.blockScopes({ modelId })[0] !== blockScope) return report;
5662
+ const scopedLimits = strategy.scopeLimits(report, { modelId });
5663
+ const meterStates = report.metadata?.meterStates;
5664
+ const meterState =
5665
+ meterStates !== null && typeof meterStates === "object"
5666
+ ? (meterStates as Record<string, { allowed?: boolean; limitReached?: boolean }>)[blockScope]
5667
+ : undefined;
5668
+ return {
5669
+ ...report,
5670
+ limits: scopedLimits,
5671
+ metadata:
5672
+ meterState || blockScope === "spark"
5673
+ ? {
5674
+ ...report.metadata,
5675
+ allowed: meterState?.allowed,
5676
+ limitReached: meterState?.limitReached,
5677
+ }
5678
+ : report.metadata,
5679
+ };
5680
+ }
5681
+
5682
+ /**
5683
+ * Clear one backoff scope. The in-memory backoff is per scope so it is
5684
+ * dropped directly; the persisted store deletes a credential's blocks as a
5685
+ * unit, so it is only purged once no other scope still holds a live block.
5686
+ * Leaving a persisted row behind is safe: the scope it belongs to is
5687
+ * unblocked in memory, and the row heals on the pass where its own meter
5688
+ * recovers.
5689
+ */
5690
+ #clearCredentialBlockScope(
5691
+ provider: string,
5692
+ credentialId: number,
5693
+ credentialIndex: number,
5694
+ providerKey: string,
5695
+ blockScope: string | undefined,
5696
+ ): void {
5697
+ const key = this.#toScopedBackoffKey(providerKey, blockScope);
5698
+ const backoffMap = this.#credentialBackoff.get(key);
5699
+ backoffMap?.delete(credentialIndex);
5700
+ if (backoffMap?.size === 0) this.#credentialBackoff.delete(key);
5701
+ const probeAfterMap = this.#credentialBackoffProbeAfter.get(key);
5702
+ probeAfterMap?.delete(credentialIndex);
5703
+ if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(key);
5704
+ try {
5705
+ this.deleteCredentialBlock(credentialId, providerKey, blockScope ?? "");
5706
+ } catch (err) {
5707
+ logger.debug("Failed to clear persisted credential block", { err, provider, credentialId, blockScope });
5708
+ }
5709
+ }
5710
+
5576
5711
  #isHealthyCodexUsageReport(report: UsageReport): boolean {
5577
5712
  if (report.provider !== "openai-codex") return false;
5578
5713
  const metadata = report.metadata;
@@ -5581,13 +5716,51 @@ export class AuthStorage {
5581
5716
  }
5582
5717
 
5583
5718
  #reconcileCodexUsageBlockForCredential(provider: Provider, credentialId: number, report: UsageReport): void {
5584
- if (!this.#isHealthyCodexUsageReport(report)) return;
5585
5719
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
5586
5720
  const credentialIndex = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
5587
5721
  if (credentialIndex < 0) return;
5588
5722
  // Mirror selection: consult the same strategy scope `markUsageLimitReached`
5589
5723
  // persists under, else a scoped block is invisible here and never healed.
5590
- const blockScope = this.#rankingStrategyResolver?.(provider)?.blockScope?.({});
5724
+ // Reconciliation has no request context, so it cannot know which scope a
5725
+ // block was written under. Heal every scope the strategy can produce, plus
5726
+ // any legacy scope it still lists, or a block persisted under one scope
5727
+ // stays invisible here forever.
5728
+ const strategy = this.#rankingStrategyResolver?.(provider);
5729
+ const blockScopes =
5730
+ strategy?.blockScopes?.() ?? [strategy?.blockScope?.({})].filter(scope => scope !== undefined);
5731
+ const scopesToHeal = blockScopes.length > 0 ? blockScopes : [undefined];
5732
+ for (const blockScope of scopesToHeal) {
5733
+ this.#healCodexUsageBlockScope(
5734
+ provider,
5735
+ providerKey,
5736
+ credentialId,
5737
+ credentialIndex,
5738
+ blockScope,
5739
+ report,
5740
+ strategy,
5741
+ );
5742
+ }
5743
+ }
5744
+
5745
+ /**
5746
+ * Heal one backoff scope against the limits that scope actually covers.
5747
+ *
5748
+ * Judging a scope by the whole report is wrong once scopes are per-meter: an
5749
+ * exhausted Spark meter would keep a recovered chat block alive, and healing
5750
+ * would then delete every scope including a block that is still valid. So
5751
+ * each scope is evaluated against its own limits and cleared on its own.
5752
+ */
5753
+ #healCodexUsageBlockScope(
5754
+ provider: Provider,
5755
+ providerKey: string,
5756
+ credentialId: number,
5757
+ credentialIndex: number,
5758
+ blockScope: string | undefined,
5759
+ report: UsageReport,
5760
+ strategy: CredentialRankingStrategy | undefined,
5761
+ ): void {
5762
+ const scopedReport = this.#scopeUsageReportToBlockScope(report, blockScope, strategy);
5763
+ if (!this.#isHealthyCodexUsageReport(scopedReport)) return;
5591
5764
  const blockedUntilMs = this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope);
5592
5765
  if (blockedUntilMs === undefined) return;
5593
5766
  // `/usage` can lag the request path that just returned 429. Fresh local or
@@ -5603,10 +5776,11 @@ export class AuthStorage {
5603
5776
  if (Math.max(globalProbeAfterMs, scopedProbeAfterMs, storeGlobalProbeAfterMs, storeScopedProbeAfterMs) > nowMs) {
5604
5777
  return;
5605
5778
  }
5606
- this.#clearCredentialBlocks(provider, credentialId);
5779
+ this.#clearCredentialBlockScope(provider, credentialId, credentialIndex, providerKey, blockScope);
5607
5780
  logger.info("Cleared stale Codex usage-limit block after healthy live usage report", {
5608
5781
  credentialId,
5609
5782
  provider,
5783
+ blockScope,
5610
5784
  clearedBlockedUntilMs: blockedUntilMs,
5611
5785
  });
5612
5786
  }
@@ -5647,7 +5821,6 @@ export class AuthStorage {
5647
5821
  #reconcileCodexUsageBlocksFromReports(reports: UsageReport[]): void {
5648
5822
  const reconciled = new Set<number>();
5649
5823
  for (const report of reports) {
5650
- if (!this.#isHealthyCodexUsageReport(report)) continue;
5651
5824
  for (const credentialId of this.#findStoredCredentialIdsForUsageReport(report)) {
5652
5825
  if (reconciled.has(credentialId)) continue;
5653
5826
  reconciled.add(credentialId);
@@ -5892,6 +6065,28 @@ export class AuthStorage {
5892
6065
  return { generation: this.#generation, generatedAt: Date.now(), credentials: entries };
5893
6066
  }
5894
6067
 
6068
+ /**
6069
+ * Disabled credential tombstones for display surfaces (`omp usage`,
6070
+ * broker `GET /v1/credentials/disabled`). Empty when the backing store
6071
+ * keeps no tombstones or the remote broker predates the endpoint.
6072
+ */
6073
+ async listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
6074
+ if (!this.#store.listDisabledCredentials) return [];
6075
+ return this.#store.listDisabledCredentials(provider, signal);
6076
+ }
6077
+
6078
+ /**
6079
+ * Force the backing store to revalidate its credential snapshot, then
6080
+ * reload. Remote broker stores re-fetch the snapshot; local stores are
6081
+ * always current, so only the reload runs. Callers that pair live
6082
+ * per-credential data with stored identities (`omp usage`) use this so a
6083
+ * disk-cached snapshot cannot misattribute fresh reports.
6084
+ */
6085
+ async revalidateCredentials(): Promise<void> {
6086
+ if (this.#store.refreshSnapshot) await this.#store.refreshSnapshot();
6087
+ await this.reload();
6088
+ }
6089
+
5895
6090
  /**
5896
6091
  * Refresh the OAuth credential with the given id through a per-credential
5897
6092
  * single-flight. Concurrent callers for the same row await the same upstream
@@ -5982,6 +6177,7 @@ export class AuthStorage {
5982
6177
  apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint,
5983
6178
  orgId: refreshed.orgId ?? attempted.orgId,
5984
6179
  orgName: refreshed.orgName ?? attempted.orgName,
6180
+ authorizedAt: refreshed.authorizedAt ?? attempted.authorizedAt,
5985
6181
  };
5986
6182
  // Persist by id: the array may have been reordered/shrunk while the
5987
6183
  // refresh was in flight, so the pre-await positional index is unsafe. A
@@ -6069,6 +6265,14 @@ export class AuthStorage {
6069
6265
  /**
6070
6266
  * Broker-server seam: clear all persisted blocks for one credential and notify snapshot waiters.
6071
6267
  */
6268
+ deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
6269
+ const deleteCredentialBlock = this.#store.deleteCredentialBlock?.bind(this.#store);
6270
+ if (!deleteCredentialBlock) return;
6271
+ deleteCredentialBlock(credentialId, providerKey, blockScope);
6272
+ this.#invalidateUsageReportCacheForProviderKey(providerKey);
6273
+ this.#bumpGeneration("credential-block");
6274
+ }
6275
+
6072
6276
  deleteCredentialBlocks(credentialId: number): void {
6073
6277
  const deleteCredentialBlocks = this.#store.deleteCredentialBlocks?.bind(this.#store);
6074
6278
  if (!deleteCredentialBlocks) return;
@@ -6152,6 +6356,9 @@ type AuthRow = {
6152
6356
  identity_key: string | null;
6153
6357
  };
6154
6358
 
6359
+ /** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */
6360
+ type DisabledAuthRow = AuthRow & { updated_at: number | null };
6361
+
6155
6362
  type CredentialBlockRow = {
6156
6363
  credential_id: number;
6157
6364
  provider_key: string;
@@ -6426,6 +6633,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6426
6633
  #db: Database;
6427
6634
  #listActiveStmt: Statement;
6428
6635
  #listActiveByProviderStmt: Statement;
6636
+ #listDisabledStmt: Statement;
6429
6637
  #listDisabledByProviderStmt: Statement;
6430
6638
  #insertStmt: Statement;
6431
6639
  #updateStmt: Statement;
@@ -6445,6 +6653,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6445
6653
  #listCredentialBlocksByCredentialStmt: Statement;
6446
6654
  #upsertCredentialBlockStmt: Statement;
6447
6655
  #deleteCredentialBlocksStmt: Statement;
6656
+ #deleteCredentialBlockStmt: Statement;
6448
6657
  #deleteExpiredCredentialBlocksStmt: Statement;
6449
6658
  #acquireCredentialRefreshLeaseStmt: Statement;
6450
6659
  #getCredentialRefreshLeaseStmt: Statement;
@@ -6469,8 +6678,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6469
6678
  this.#listActiveByProviderStmt = this.#db.prepare(
6470
6679
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
6471
6680
  );
6681
+ this.#listDisabledStmt = this.#db.prepare(
6682
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC",
6683
+ );
6472
6684
  this.#listDisabledByProviderStmt = this.#db.prepare(
6473
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
6685
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
6474
6686
  );
6475
6687
  this.#insertStmt = this.#db.prepare(
6476
6688
  `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
@@ -6532,6 +6744,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6532
6744
  updated_at = excluded.updated_at`,
6533
6745
  );
6534
6746
  this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?");
6747
+ this.#deleteCredentialBlockStmt = this.#db.prepare(
6748
+ "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?",
6749
+ );
6535
6750
  this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare(
6536
6751
  "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?",
6537
6752
  );
@@ -6979,6 +7194,34 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6979
7194
  return results;
6980
7195
  }
6981
7196
 
7197
+ async listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]> {
7198
+ const rows =
7199
+ (provider
7200
+ ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[])
7201
+ : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? [];
7202
+ const results: DisabledCredentialSummary[] = [];
7203
+ for (const row of rows) {
7204
+ const credential = deserializeCredential(row);
7205
+ const summary: DisabledCredentialSummary = {
7206
+ id: row.id,
7207
+ provider: row.provider,
7208
+ type: row.credential_type === "api_key" ? "api_key" : "oauth",
7209
+ cause: row.disabled_cause ?? "disabled",
7210
+ };
7211
+ if (credential?.type === "oauth") {
7212
+ if (credential.email) summary.email = credential.email;
7213
+ if (credential.accountId) summary.accountId = credential.accountId;
7214
+ if (credential.orgId) summary.orgId = credential.orgId;
7215
+ if (credential.orgName) summary.orgName = credential.orgName;
7216
+ }
7217
+ if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) {
7218
+ summary.disabledAtMs = row.updated_at * 1000;
7219
+ }
7220
+ results.push(summary);
7221
+ }
7222
+ return results;
7223
+ }
7224
+
6982
7225
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
6983
7226
  const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
6984
7227
  const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
@@ -7296,6 +7539,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7296
7539
  );
7297
7540
  }
7298
7541
 
7542
+ deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
7543
+ this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope);
7544
+ this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`);
7545
+ }
7546
+
7299
7547
  deleteCredentialBlocks(credentialId: number): void {
7300
7548
  this.#deleteCredentialBlocksStmt.run(credentialId);
7301
7549
  for (const key of this.#credentialBlockReconcileAfter.keys()) {
@@ -7651,6 +7899,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7651
7899
  this.#closed = true;
7652
7900
  this.#listActiveStmt.finalize();
7653
7901
  this.#listActiveByProviderStmt.finalize();
7902
+ this.#listDisabledStmt.finalize();
7654
7903
  this.#listDisabledByProviderStmt.finalize();
7655
7904
  this.#insertStmt.finalize();
7656
7905
  this.#updateStmt.finalize();
@@ -7666,6 +7915,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7666
7915
  this.#listCredentialBlocksByCredentialStmt.finalize();
7667
7916
  this.#upsertCredentialBlockStmt.finalize();
7668
7917
  this.#deleteCredentialBlocksStmt.finalize();
7918
+ this.#deleteCredentialBlockStmt.finalize();
7669
7919
  this.#deleteExpiredCredentialBlocksStmt.finalize();
7670
7920
  this.#insertUsageHistoryStmt.finalize();
7671
7921
  this.#lastUsageHistoryStmt.finalize();
@@ -90,7 +90,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
90
90
  const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
91
91
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
92
92
  export const TRANSIENT_TRANSPORT_PATTERN =
93
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
93
+ /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
94
94
  const AUTH_FAILURE_PATTERN =
95
95
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
96
96
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;