@oh-my-pi/pi-ai 16.3.14 → 16.4.0

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 (53) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +5 -2
  3. package/dist/types/auth-broker/remote-store.d.ts +1 -0
  4. package/dist/types/auth-broker/wire-schemas.d.ts +8 -0
  5. package/dist/types/auth-storage.d.ts +5 -0
  6. package/dist/types/providers/azure-openai-responses.d.ts +1 -1
  7. package/dist/types/providers/ollama.d.ts +1 -1
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +1 -1
  9. package/dist/types/providers/openai-chat-wire.d.ts +1 -1
  10. package/dist/types/providers/openai-codex/request-transformer.d.ts +43 -5
  11. package/dist/types/providers/openai-codex-responses.d.ts +51 -8
  12. package/dist/types/providers/openai-completions.d.ts +1 -1
  13. package/dist/types/providers/openai-responses-wire.d.ts +16 -7
  14. package/dist/types/providers/openai-responses.d.ts +1 -1
  15. package/dist/types/providers/openai-shared.d.ts +38 -8
  16. package/dist/types/registry/novita.d.ts +6 -0
  17. package/dist/types/registry/oauth/device-code.d.ts +25 -0
  18. package/dist/types/registry/oauth/index.d.ts +1 -25
  19. package/dist/types/registry/oauth/xai-oauth.d.ts +9 -25
  20. package/dist/types/registry/registry.d.ts +4 -1
  21. package/dist/types/registry/xai-oauth.d.ts +0 -1
  22. package/dist/types/types.d.ts +26 -3
  23. package/package.json +4 -4
  24. package/src/auth-broker/remote-store.ts +60 -5
  25. package/src/auth-broker/server.ts +1 -0
  26. package/src/auth-broker/wire-schemas.ts +1 -0
  27. package/src/auth-gateway/server.ts +2 -2
  28. package/src/auth-storage.ts +198 -60
  29. package/src/error/flags.ts +4 -1
  30. package/src/error/rate-limit.ts +7 -1
  31. package/src/providers/amazon-bedrock.ts +1 -0
  32. package/src/providers/anthropic-messages-server.ts +18 -0
  33. package/src/providers/azure-openai-responses.ts +3 -3
  34. package/src/providers/ollama.ts +1 -1
  35. package/src/providers/openai-chat-server-schema.ts +1 -1
  36. package/src/providers/openai-chat-server.ts +8 -1
  37. package/src/providers/openai-chat-wire.ts +1 -1
  38. package/src/providers/openai-codex/request-transformer.ts +94 -41
  39. package/src/providers/openai-codex-responses.ts +475 -39
  40. package/src/providers/openai-completions.ts +38 -12
  41. package/src/providers/openai-responses-server.ts +8 -1
  42. package/src/providers/openai-responses-wire.ts +16 -7
  43. package/src/providers/openai-responses.ts +18 -5
  44. package/src/providers/openai-shared.ts +125 -13
  45. package/src/registry/novita.ts +22 -0
  46. package/src/registry/oauth/__tests__/xai-oauth.test.ts +191 -80
  47. package/src/registry/oauth/device-code.ts +92 -0
  48. package/src/registry/oauth/index.ts +1 -91
  49. package/src/registry/oauth/xai-oauth.ts +231 -191
  50. package/src/registry/registry.ts +2 -0
  51. package/src/registry/xai-oauth.ts +0 -1
  52. package/src/stream.ts +7 -1
  53. package/src/types.ts +29 -3
@@ -134,6 +134,8 @@ export interface StoredCredentialBlock {
134
134
  blockScope: string;
135
135
  /** Epoch milliseconds. */
136
136
  blockedUntilMs: number;
137
+ /** Last row update timestamp in epoch milliseconds, when provided by the backing store. */
138
+ updatedAtMs?: number;
137
139
  }
138
140
 
139
141
  /**
@@ -320,6 +322,8 @@ export interface AuthCredentialStore {
320
322
  cleanExpiredCache(): void;
321
323
  /** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
322
324
  getCredentialBlock?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
325
+ /** Earliest time a shared-store block should be eligible for live-usage reconciliation. */
326
+ getCredentialBlockReconcileAfter?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
323
327
  /** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */
324
328
  upsertCredentialBlock?(block: StoredCredentialBlock): void;
325
329
  /** Drop every block row for a credential (all providerKeys/scopes). */
@@ -761,25 +765,84 @@ function isAbortSignalOption(
761
765
  return typeof value === "object" && value !== null && "aborted" in value && "addEventListener" in value;
762
766
  }
763
767
 
764
- function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
765
- return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
768
+ type OpenAICodexPlanRequirement = "none" | "paid" | "pro";
769
+ type OpenAICodexPlanClass = "free" | "paid" | "pro" | "unknown";
770
+
771
+ const GPT_56_PAID_CODEX_MODEL_PATTERN = /^gpt-5\.6-(?:sol|luna)(?:-pro)?$/;
772
+ const OPENAI_CODEX_PRO_PLAN_TOKENS: Record<string, true> = {
773
+ pro: true,
774
+ };
775
+ const OPENAI_CODEX_PAID_PLAN_TOKENS: Record<string, true> = {
776
+ plus: true,
777
+ business: true,
778
+ team: true,
779
+ enterprise: true,
780
+ edu: true,
781
+ education: true,
782
+ teacher: true,
783
+ teachers: true,
784
+ health: true,
785
+ gov: true,
786
+ government: true,
787
+ };
788
+ const OPENAI_CODEX_FREE_PLAN_TOKENS: Record<string, true> = {
789
+ free: true,
790
+ go: true,
791
+ };
792
+
793
+ /**
794
+ * Account tier needed for model-aware Codex OAuth routing.
795
+ *
796
+ * GPT-5.6 Terra (including its local pro-mode alias) remains available on every
797
+ * plan. Sol and Luna pro-mode aliases inherit their base models' paid tier;
798
+ * only Spark currently has a documented Pro-plan preference in Codex.
799
+ */
800
+ function resolveOpenAICodexPlanRequirement(provider: string, modelId: string | undefined): OpenAICodexPlanRequirement {
801
+ if (provider !== "openai-codex" || typeof modelId !== "string") return "none";
802
+ const separator = modelId.lastIndexOf("/");
803
+ const bareModelId = (separator === -1 ? modelId : modelId.slice(separator + 1)).toLowerCase();
804
+ if (bareModelId.includes("-spark")) return "pro";
805
+ if (bareModelId === "gpt-5.6" || GPT_56_PAID_CODEX_MODEL_PATTERN.test(bareModelId)) return "paid";
806
+ return "none";
766
807
  }
767
808
 
768
809
  function getUsagePlanType(report: UsageReport | null): string | undefined {
769
810
  const metadata = report?.metadata;
770
- if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
771
- const planType = (metadata as { planType?: unknown }).planType;
772
- return typeof planType === "string" ? planType.toLowerCase() : undefined;
811
+ if (!metadata) return undefined;
812
+ const planType = metadata.planType;
813
+ if (typeof planType !== "string") return undefined;
814
+ const normalized = planType
815
+ .trim()
816
+ .toLowerCase()
817
+ .replace(/[\s-]+/g, "_");
818
+ return normalized.startsWith("chatgpt_") ? normalized.slice("chatgpt_".length) : normalized;
773
819
  }
774
820
 
775
- function getOpenAICodexPlanPriority(report: UsageReport | null): number {
821
+ function classifyOpenAICodexPlan(report: UsageReport | null): OpenAICodexPlanClass {
776
822
  const planType = getUsagePlanType(report);
777
- if (!planType) return 1;
778
- return planType.includes("pro") ? 0 : 2;
823
+ if (!planType) return "unknown";
824
+ // Pro Lite is a paid Codex tier, but does not imply full Pro-only model access.
825
+ if (planType === "prolite" || planType === "pro_lite") return "paid";
826
+ const tokens = planType.split("_");
827
+ if (tokens.some(token => OPENAI_CODEX_PRO_PLAN_TOKENS[token] === true)) return "pro";
828
+ if (tokens.some(token => OPENAI_CODEX_PAID_PLAN_TOKENS[token] === true)) return "paid";
829
+ if (tokens.some(token => OPENAI_CODEX_FREE_PLAN_TOKENS[token] === true)) return "free";
830
+ return "unknown";
831
+ }
832
+
833
+ function getOpenAICodexPlanEligibility(
834
+ report: UsageReport | null,
835
+ requirement: OpenAICodexPlanRequirement,
836
+ ): boolean | undefined {
837
+ if (requirement === "none") return true;
838
+ const planClass = classifyOpenAICodexPlan(report);
839
+ if (planClass === "unknown") return undefined;
840
+ return requirement === "paid" ? planClass !== "free" : planClass === "pro";
779
841
  }
780
842
 
781
- function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
782
- return getUsagePlanType(report)?.includes("pro") === true;
843
+ function getOpenAICodexPlanPriority(report: UsageReport | null, requirement: OpenAICodexPlanRequirement): number {
844
+ const eligibility = getOpenAICodexPlanEligibility(report, requirement);
845
+ return eligibility === true ? 0 : eligibility === undefined ? 1 : 2;
783
846
  }
784
847
 
785
848
  function compareUsageRankingMetric(left: number, right: number): number {
@@ -965,6 +1028,8 @@ export class AuthStorage {
965
1028
  #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
966
1029
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
967
1030
  #credentialBackoff: Map<string, Map<number, number>> = new Map();
1031
+ /** Earliest time a freshly-set in-memory block may be cleared by live usage reconciliation. */
1032
+ #credentialBackoffProbeAfter: Map<string, Map<number, number>> = new Map();
968
1033
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
969
1034
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
970
1035
  #usageCache: UsageCache;
@@ -1345,6 +1410,9 @@ export class AuthStorage {
1345
1410
  if (backoffMap.size === 0) {
1346
1411
  this.#credentialBackoff.delete(backoffKey);
1347
1412
  }
1413
+ const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey);
1414
+ probeAfterMap?.delete(credentialIndex);
1415
+ if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(backoffKey);
1348
1416
  return undefined;
1349
1417
  }
1350
1418
  return blockedUntil;
@@ -1437,6 +1505,9 @@ export class AuthStorage {
1437
1505
  const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1438
1506
  backoffMap.set(credentialIndex, nextBlockedUntil);
1439
1507
  this.#credentialBackoff.set(backoffKey, backoffMap);
1508
+ const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey) ?? new Map<number, number>();
1509
+ probeAfterMap.set(credentialIndex, Math.min(nextBlockedUntil, Date.now() + USAGE_REPORT_TTL_MS));
1510
+ this.#credentialBackoffProbeAfter.set(backoffKey, probeAfterMap);
1440
1511
  this.#invalidateUsageReportCache(provider);
1441
1512
 
1442
1513
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
@@ -3186,8 +3257,7 @@ export class AuthStorage {
3186
3257
  #compareRankedOAuthCandidatePriority(
3187
3258
  left: RankedOAuthCandidate,
3188
3259
  right: RankedOAuthCandidate,
3189
- provider: string,
3190
- modelId: string | undefined,
3260
+ planRequirement: OpenAICodexPlanRequirement,
3191
3261
  ): number {
3192
3262
  if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
3193
3263
  if (left.blocked && right.blocked) {
@@ -3196,7 +3266,7 @@ export class AuthStorage {
3196
3266
  if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
3197
3267
  return 0;
3198
3268
  }
3199
- if (requiresOpenAICodexProModel(provider, modelId) && left.planPriority !== right.planPriority) {
3269
+ if (planRequirement !== "none" && left.planPriority !== right.planPriority) {
3200
3270
  return left.planPriority - right.planPriority;
3201
3271
  }
3202
3272
  if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
@@ -3214,20 +3284,18 @@ export class AuthStorage {
3214
3284
  #compareRankedOAuthCandidates(
3215
3285
  left: RankedOAuthCandidate,
3216
3286
  right: RankedOAuthCandidate,
3217
- provider: string,
3218
- modelId: string | undefined,
3287
+ planRequirement: OpenAICodexPlanRequirement,
3219
3288
  ): number {
3220
- const priority = this.#compareRankedOAuthCandidatePriority(left, right, provider, modelId);
3289
+ const priority = this.#compareRankedOAuthCandidatePriority(left, right, planRequirement);
3221
3290
  return priority !== 0 ? priority : left.orderPos - right.orderPos;
3222
3291
  }
3223
3292
 
3224
3293
  #orderRankedOAuthCandidates(
3225
3294
  candidates: RankedOAuthCandidate[],
3226
3295
  sessionId: string | undefined,
3227
- provider: string,
3228
- modelId: string | undefined,
3296
+ planRequirement: OpenAICodexPlanRequirement,
3229
3297
  ): OAuthCandidate[] {
3230
- candidates.sort((left, right) => this.#compareRankedOAuthCandidates(left, right, provider, modelId));
3298
+ candidates.sort((left, right) => this.#compareRankedOAuthCandidates(left, right, planRequirement));
3231
3299
  if (!sessionId) {
3232
3300
  return candidates.map(candidate => ({
3233
3301
  selection: candidate.selection,
@@ -3252,7 +3320,7 @@ export class AuthStorage {
3252
3320
  for (const candidate of unblocked) {
3253
3321
  if (
3254
3322
  candidate !== previous &&
3255
- this.#compareRankedOAuthCandidatePriority(previous, candidate, provider, modelId) !== 0
3323
+ this.#compareRankedOAuthCandidatePriority(previous, candidate, planRequirement) !== 0
3256
3324
  ) {
3257
3325
  bucketIndex += 1;
3258
3326
  }
@@ -3297,6 +3365,7 @@ export class AuthStorage {
3297
3365
  providerKey: string;
3298
3366
  provider: string;
3299
3367
  order: number[];
3368
+ planRequirement: OpenAICodexPlanRequirement;
3300
3369
  credentials: OAuthSelection[];
3301
3370
  options?: AuthApiKeyOptions;
3302
3371
  sessionId?: string;
@@ -3316,18 +3385,36 @@ export class AuthStorage {
3316
3385
  args.order.map(async idx => {
3317
3386
  const selection = args.credentials[idx];
3318
3387
  if (!selection) return null;
3319
- const blockedUntil = this.#getCredentialBlockedUntil(
3388
+ let blockedUntil = this.#getCredentialBlockedUntil(
3320
3389
  args.provider,
3321
3390
  args.providerKey,
3322
3391
  selection.index,
3323
3392
  args.blockScope,
3324
3393
  );
3325
- if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
3326
- const usage = await this.#getUsageReport(args.provider, selection.credential, {
3327
- ...args.options,
3328
- timeoutMs: this.#usageRequestTimeoutMs,
3329
- });
3330
- return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
3394
+ let usage: UsageReport | null = null;
3395
+ let usageChecked = false;
3396
+ if (blockedUntil !== undefined && args.provider === "openai-codex") {
3397
+ usage = await this.#getUsageReport(args.provider, selection.credential, {
3398
+ ...args.options,
3399
+ timeoutMs: this.#usageRequestTimeoutMs,
3400
+ });
3401
+ usageChecked = true;
3402
+ blockedUntil = this.#getCredentialBlockedUntil(
3403
+ args.provider,
3404
+ args.providerKey,
3405
+ selection.index,
3406
+ args.blockScope,
3407
+ );
3408
+ }
3409
+ if (blockedUntil !== undefined) return { selection, usage, usageChecked, blockedUntil };
3410
+ if (!usageChecked) {
3411
+ usage = await this.#getUsageReport(args.provider, selection.credential, {
3412
+ ...args.options,
3413
+ timeoutMs: this.#usageRequestTimeoutMs,
3414
+ });
3415
+ usageChecked = true;
3416
+ }
3417
+ return { selection, usage, usageChecked, blockedUntil: undefined as number | undefined };
3331
3418
  }),
3332
3419
  );
3333
3420
  const timeoutSignal = Promise.withResolvers<null>();
@@ -3378,7 +3465,7 @@ export class AuthStorage {
3378
3465
  blocked,
3379
3466
  blockedUntil,
3380
3467
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
3381
- planPriority: getOpenAICodexPlanPriority(usage),
3468
+ planPriority: getOpenAICodexPlanPriority(usage, args.planRequirement),
3382
3469
  secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
3383
3470
  secondaryDrainRate: this.#computeWindowDrainRate(
3384
3471
  secondaryTarget,
@@ -3390,7 +3477,7 @@ export class AuthStorage {
3390
3477
  orderPos,
3391
3478
  });
3392
3479
  }
3393
- return this.#orderRankedOAuthCandidates(ranked, args.sessionId, args.provider, args.options?.modelId);
3480
+ return this.#orderRankedOAuthCandidates(ranked, args.sessionId, args.planRequirement);
3394
3481
  }
3395
3482
 
3396
3483
  /**
@@ -3418,8 +3505,9 @@ export class AuthStorage {
3418
3505
  const strategy = this.#rankingStrategyResolver?.(provider);
3419
3506
  const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
3420
3507
  const blockScope = strategy?.blockScope?.(rankingContext);
3421
- const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
3422
- const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
3508
+ const planRequirement = resolveOpenAICodexPlanRequirement(provider, options?.modelId);
3509
+ const hasPlanRequirement = planRequirement !== "none";
3510
+ const checkUsage = strategy !== undefined && (credentials.length > 1 || hasPlanRequirement);
3423
3511
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
3424
3512
  const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
3425
3513
  const sessionPreferredCredential =
@@ -3438,12 +3526,13 @@ export class AuthStorage {
3438
3526
  sessionPreferredIndex !== undefined &&
3439
3527
  sessionPreferredCanRefreshOrUse &&
3440
3528
  !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
3441
- const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
3529
+ const shouldRank = checkUsage && (!sessionPreferredIsAvailable || hasPlanRequirement);
3442
3530
  const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
3443
3531
  const candidates = shouldRank
3444
3532
  ? await this.#rankOAuthSelections({
3445
3533
  providerKey,
3446
3534
  provider,
3535
+ planRequirement,
3447
3536
  order: rankingOrder,
3448
3537
  credentials,
3449
3538
  options,
@@ -3457,7 +3546,7 @@ export class AuthStorage {
3457
3546
  .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
3458
3547
  .map(selection => ({ selection, usage: null, usageChecked: false }));
3459
3548
 
3460
- if (sessionPreferredIndex !== undefined && !requiresProModel) {
3549
+ if (sessionPreferredIndex !== undefined && !hasPlanRequirement) {
3461
3550
  const sessionPreferredCandidate = candidates.findIndex(
3462
3551
  candidate =>
3463
3552
  !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
@@ -3537,10 +3626,12 @@ export class AuthStorage {
3537
3626
  }),
3538
3627
  );
3539
3628
 
3540
- // Skip the Pro-plan filter when no candidate is confirmed Pro, so users with only
3541
- // non-Pro accounts can still attempt Spark requests (e.g. trial/grandfathered access).
3542
- const enforceProRequirement =
3543
- requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
3629
+ // Enforce a tier only when at least one account is confirmed eligible. If
3630
+ // every report is unknown or ineligible, preserve trial/grandfathered access
3631
+ // by allowing the normal candidate fallback to attempt the request.
3632
+ const enforcePlanRequirement =
3633
+ hasPlanRequirement &&
3634
+ candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
3544
3635
 
3545
3636
  const fallback = candidates[0];
3546
3637
 
@@ -3556,7 +3647,8 @@ export class AuthStorage {
3556
3647
  allowBlocked: false,
3557
3648
  prefetchedUsage: candidate.usage,
3558
3649
  usagePrechecked: candidate.usageChecked,
3559
- enforceProRequirement,
3650
+ planRequirement,
3651
+ enforcePlanRequirement,
3560
3652
  strategy,
3561
3653
  rankingContext,
3562
3654
  blockScope,
@@ -3571,7 +3663,8 @@ export class AuthStorage {
3571
3663
  allowBlocked: true,
3572
3664
  prefetchedUsage: fallback.usage,
3573
3665
  usagePrechecked: fallback.usageChecked,
3574
- enforceProRequirement,
3666
+ planRequirement,
3667
+ enforcePlanRequirement,
3575
3668
  strategy,
3576
3669
  rankingContext,
3577
3670
  blockScope,
@@ -3709,7 +3802,8 @@ export class AuthStorage {
3709
3802
  allowBlocked: boolean;
3710
3803
  prefetchedUsage?: UsageReport | null;
3711
3804
  usagePrechecked?: boolean;
3712
- enforceProRequirement?: boolean;
3805
+ planRequirement?: OpenAICodexPlanRequirement;
3806
+ enforcePlanRequirement?: boolean;
3713
3807
  strategy?: CredentialRankingStrategy;
3714
3808
  rankingContext?: CredentialRankingContext;
3715
3809
  blockScope?: string;
@@ -3722,7 +3816,8 @@ export class AuthStorage {
3722
3816
  allowBlocked,
3723
3817
  prefetchedUsage = null,
3724
3818
  usagePrechecked = false,
3725
- enforceProRequirement,
3819
+ planRequirement: providedPlanRequirement,
3820
+ enforcePlanRequirement,
3726
3821
  strategy,
3727
3822
  rankingContext,
3728
3823
  blockScope,
@@ -3741,12 +3836,13 @@ export class AuthStorage {
3741
3836
  // refresh / persist / CAS-disable addresses the row by this stable id.
3742
3837
  const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3743
3838
 
3744
- const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
3745
- const applyProFilter = enforceProRequirement ?? requiresProModel;
3839
+ const planRequirement = providedPlanRequirement ?? resolveOpenAICodexPlanRequirement(provider, options?.modelId);
3840
+ const hasPlanRequirement = planRequirement !== "none";
3841
+ const applyPlanFilter = enforcePlanRequirement ?? hasPlanRequirement;
3746
3842
  let usage: UsageReport | null = null;
3747
3843
  let usageChecked = false;
3748
3844
 
3749
- if ((checkUsage && !allowBlocked) || requiresProModel) {
3845
+ if ((checkUsage && !allowBlocked) || hasPlanRequirement) {
3750
3846
  if (usagePrechecked) {
3751
3847
  usage = prefetchedUsage;
3752
3848
  usageChecked = true;
@@ -3757,7 +3853,7 @@ export class AuthStorage {
3757
3853
  });
3758
3854
  usageChecked = true;
3759
3855
  }
3760
- if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
3856
+ if (applyPlanFilter && getOpenAICodexPlanEligibility(usage, planRequirement) !== true) {
3761
3857
  return undefined;
3762
3858
  }
3763
3859
  if (checkUsage && !allowBlocked && usage && strategy && rankingContext) {
@@ -3825,7 +3921,7 @@ export class AuthStorage {
3825
3921
  } else {
3826
3922
  this.#replaceCredentialAt(provider, selection.index, updated);
3827
3923
  }
3828
- if ((checkUsage && !allowBlocked) || requiresProModel) {
3924
+ if ((checkUsage && !allowBlocked) || hasPlanRequirement) {
3829
3925
  const sameAccount = selection.credential.accountId === updated.accountId;
3830
3926
  if (!usageChecked || !sameAccount) {
3831
3927
  usage = await this.#getUsageReport(provider, updated, {
@@ -3834,7 +3930,7 @@ export class AuthStorage {
3834
3930
  });
3835
3931
  usageChecked = true;
3836
3932
  }
3837
- if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
3933
+ if (applyPlanFilter && getOpenAICodexPlanEligibility(usage, planRequirement) !== true) {
3838
3934
  return undefined;
3839
3935
  }
3840
3936
  if (checkUsage && !allowBlocked && usage && strategy && rankingContext) {
@@ -4367,23 +4463,24 @@ export class AuthStorage {
4367
4463
  backoffMap.delete(index);
4368
4464
  if (backoffMap.size === 0) this.#credentialBackoff.delete(key);
4369
4465
  }
4466
+ for (const [key, probeAfterMap] of this.#credentialBackoffProbeAfter) {
4467
+ if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
4468
+ probeAfterMap.delete(index);
4469
+ if (probeAfterMap.size === 0) this.#credentialBackoffProbeAfter.delete(key);
4470
+ }
4370
4471
  }
4371
4472
 
4372
4473
  /**
4373
4474
  * Self-heal a stale Codex usage-limit block: when a fresh live usage report
4374
- * shows the account is allowed and below every limit, drop the persisted and
4375
- * in-memory `openai-codex:oauth` blocks so the balancer re-includes it. Only
4376
- * Codex its ranking strategy uses the single model-independent `"shared"`
4377
- * scope, so clearing every block for the credential id is exact.
4475
+ * says the account is allowed and below every reported limit, drop the
4476
+ * persisted and in-memory `openai-codex:oauth` blocks so credential selection
4477
+ * can re-include recovered seats before a stale block naturally expires.
4378
4478
  */
4379
4479
  #isHealthyCodexUsageReport(report: UsageReport): boolean {
4480
+ if (report.provider !== "openai-codex") return false;
4380
4481
  const metadata = report.metadata;
4381
- return (
4382
- report.provider === "openai-codex" &&
4383
- metadata?.allowed === true &&
4384
- metadata.limitReached === false &&
4385
- !this.#isUsageLimitReached(report.limits)
4386
- );
4482
+ if (metadata?.allowed !== true || metadata.limitReached !== false) return false;
4483
+ return !this.#isUsageLimitReached(report.limits);
4387
4484
  }
4388
4485
 
4389
4486
  #reconcileCodexUsageBlockForCredential(provider: Provider, credentialId: number, report: UsageReport): void {
@@ -4396,6 +4493,19 @@ export class AuthStorage {
4396
4493
  const blockScope = this.#rankingStrategyResolver?.(provider)?.blockScope?.({});
4397
4494
  const blockedUntilMs = this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope);
4398
4495
  if (blockedUntilMs === undefined) return;
4496
+ // `/usage` can lag the request path that just returned 429. Fresh local or
4497
+ // broker-sourced blocks get one usage-cache window before healthy reports may
4498
+ // clear them.
4499
+ const nowMs = Date.now();
4500
+ const scopedBackoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
4501
+ const globalProbeAfterMs = this.#credentialBackoffProbeAfter.get(providerKey)?.get(credentialIndex) ?? 0;
4502
+ const scopedProbeAfterMs = this.#credentialBackoffProbeAfter.get(scopedBackoffKey)?.get(credentialIndex) ?? 0;
4503
+ const getStoreReconcileAfter = this.#store.getCredentialBlockReconcileAfter?.bind(this.#store);
4504
+ const storeGlobalProbeAfterMs = getStoreReconcileAfter?.(credentialId, providerKey, "") ?? 0;
4505
+ const storeScopedProbeAfterMs = getStoreReconcileAfter?.(credentialId, providerKey, blockScope ?? "") ?? 0;
4506
+ if (Math.max(globalProbeAfterMs, scopedProbeAfterMs, storeGlobalProbeAfterMs, storeScopedProbeAfterMs) > nowMs) {
4507
+ return;
4508
+ }
4399
4509
  this.#clearCredentialBlocks(provider, credentialId);
4400
4510
  logger.info("Cleared stale Codex usage-limit block after healthy live usage report", {
4401
4511
  credentialId,
@@ -4899,6 +5009,7 @@ type CredentialBlockRow = {
4899
5009
  provider_key: string;
4900
5010
  block_scope: string;
4901
5011
  blocked_until_ms: number;
5012
+ updated_at: number;
4902
5013
  };
4903
5014
 
4904
5015
  type SerializedCredentialRecord = {
@@ -5114,6 +5225,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5114
5225
  #upsertCredentialBlockStmt: Statement;
5115
5226
  #deleteCredentialBlocksStmt: Statement;
5116
5227
  #deleteExpiredCredentialBlocksStmt: Statement;
5228
+ #credentialBlockReconcileAfter: Map<string, number> = new Map();
5117
5229
  #insertUsageHistoryStmt: Statement;
5118
5230
  #insertUsageCostStmt: Statement;
5119
5231
  #listUsageCostsStmt: Statement;
@@ -5160,10 +5272,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5160
5272
  );
5161
5273
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
5162
5274
  this.#getCredentialBlockStmt = this.#db.prepare(
5163
- "SELECT blocked_until_ms FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
5275
+ "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
5164
5276
  );
5165
5277
  this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
5166
- "SELECT credential_id, provider_key, block_scope, blocked_until_ms FROM auth_credential_blocks WHERE credential_id = ? AND blocked_until_ms > ? ORDER BY provider_key ASC, block_scope ASC",
5278
+ "SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND blocked_until_ms > ? ORDER BY provider_key ASC, block_scope ASC",
5167
5279
  );
5168
5280
  this.#upsertCredentialBlockStmt = this.#db.prepare(
5169
5281
  `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
@@ -5777,11 +5889,26 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5777
5889
  const nowMs = Date.now();
5778
5890
  this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5779
5891
  const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
5780
- | { blocked_until_ms?: number }
5892
+ | { blocked_until_ms?: number; updated_at?: number }
5781
5893
  | undefined;
5782
5894
  return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined;
5783
5895
  }
5784
5896
 
5897
+ getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
5898
+ const nowMs = Date.now();
5899
+ this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5900
+ const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
5901
+ | { blocked_until_ms?: number; updated_at?: number }
5902
+ | undefined;
5903
+ if (typeof row?.blocked_until_ms !== "number") return undefined;
5904
+ const memoryReconcileAfter =
5905
+ this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0;
5906
+ const persistedReconcileAfter =
5907
+ typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0;
5908
+ const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter);
5909
+ return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined;
5910
+ }
5911
+
5785
5912
  upsertCredentialBlock(block: StoredCredentialBlock): void {
5786
5913
  this.#upsertCredentialBlockStmt.run(
5787
5914
  block.credentialId,
@@ -5789,14 +5916,24 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5789
5916
  block.blockScope,
5790
5917
  block.blockedUntilMs,
5791
5918
  );
5919
+ this.#credentialBlockReconcileAfter.set(
5920
+ `${block.credentialId}\0${block.providerKey}\0${block.blockScope}`,
5921
+ Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS),
5922
+ );
5792
5923
  }
5793
5924
 
5794
5925
  deleteCredentialBlocks(credentialId: number): void {
5795
5926
  this.#deleteCredentialBlocksStmt.run(credentialId);
5927
+ for (const key of this.#credentialBlockReconcileAfter.keys()) {
5928
+ if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
5929
+ }
5796
5930
  }
5797
5931
 
5798
5932
  cleanExpiredCredentialBlocks(nowMs: number): void {
5799
5933
  this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5934
+ for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
5935
+ if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
5936
+ }
5800
5937
  }
5801
5938
 
5802
5939
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
@@ -5815,6 +5952,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5815
5952
  providerKey: row.provider_key,
5816
5953
  blockScope: row.block_scope,
5817
5954
  blockedUntilMs: row.blocked_until_ms,
5955
+ updatedAtMs: row.updated_at * 1000,
5818
5956
  });
5819
5957
  }
5820
5958
  }
@@ -1,5 +1,6 @@
1
1
  import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils";
2
2
  import type { Api, AssistantMessage } from "../types";
3
+ import { AwsCredentialsError } from "./aws";
3
4
  import {
4
5
  AnthropicConnectionError,
5
6
  AnthropicConnectionTimeoutError,
@@ -346,7 +347,9 @@ export function classify(error: unknown, api?: Api): number {
346
347
  }
347
348
  }
348
349
 
349
- if (link instanceof AnthropicConnectionTimeoutError) {
350
+ if (link instanceof AwsCredentialsError) {
351
+ kinds |= Flag.AuthFailed;
352
+ } else if (link instanceof AnthropicConnectionTimeoutError) {
350
353
  kinds |= Flag.Timeout | Flag.Transient;
351
354
  } else if (link instanceof AnthropicConnectionError) {
352
355
  kinds |= Flag.Transient;
@@ -67,6 +67,12 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
67
67
  lower.includes("exhausted") ||
68
68
  lower.includes("quota") ||
69
69
  lower.includes("usage limit") ||
70
+ // xAI SuperGrok: HTTP 403 "run out of credits" / spending-limit is an
71
+ // account-local cap — rotate, don't treat as auth failure.
72
+ lower.includes("run out of credits") ||
73
+ lower.includes("out of credits") ||
74
+ lower.includes("spending-limit") ||
75
+ lower.includes("spending limit") ||
70
76
  INSUFFICIENT_BALANCE_PATTERN.test(errorMessage)
71
77
  ) {
72
78
  return "QUOTA_EXHAUSTED";
@@ -100,7 +106,7 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
100
106
 
101
107
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
102
108
  const USAGE_LIMIT_PATTERN =
103
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)/i;
109
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
104
110
 
105
111
  /**
106
112
  * HTTP status codes that, absent richer body classification, represent an
@@ -997,6 +997,7 @@ function buildAdditionalModelRequestFields(
997
997
  medium: 8192,
998
998
  high: 16384,
999
999
  xhigh: 32768,
1000
+ max: 32768,
1000
1001
  };
1001
1002
  const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[level];
1002
1003
 
@@ -1,3 +1,4 @@
1
+ import { Effort } from "@oh-my-pi/pi-catalog/effort";
1
2
  import { logger } from "@oh-my-pi/pi-utils";
2
3
  import { type } from "arktype";
3
4
  import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
@@ -291,6 +292,19 @@ function deriveCacheRetention(data: {
291
292
  return strongest;
292
293
  }
293
294
 
295
+ /**
296
+ * Inbound `output_config.effort` wire literal → catalog `Effort` (1:1).
297
+ * Values outside this table (none exist in the schema today) are ignored
298
+ * rather than guessed at.
299
+ */
300
+ const REASONING_EFFORT_BY_WIRE: Partial<Record<string, Effort>> = {
301
+ low: Effort.Low,
302
+ medium: Effort.Medium,
303
+ high: Effort.High,
304
+ xhigh: Effort.XHigh,
305
+ max: Effort.Max,
306
+ };
307
+
294
308
  export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
295
309
  const data = anthropicMessagesRequestSchema(body);
296
310
  if (data instanceof type.errors) {
@@ -351,6 +365,10 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
351
365
  if (data.output_config?.task_budget) {
352
366
  options.taskBudget = data.output_config.task_budget;
353
367
  }
368
+ if (data.output_config?.effort) {
369
+ const mapped = REASONING_EFFORT_BY_WIRE[data.output_config.effort];
370
+ if (mapped !== undefined) options.reasoning = mapped;
371
+ }
354
372
  const cacheRetention = deriveCacheRetention(data);
355
373
  if (cacheRetention !== undefined) options.cacheRetention = cacheRetention;
356
374
  // Anthropic clients commonly send `metadata: { user_id }`; forward verbatim
@@ -34,7 +34,7 @@ import {
34
34
  applyResponsesReasoningParams,
35
35
  buildResponsesInput,
36
36
  createInitialResponsesAssistantMessage,
37
- getOpenAIResponsesPromptCacheKey,
37
+ getOpenAIPromptCacheKey,
38
38
  isOpenAIResponsesProgressEvent,
39
39
  parseAzureDeploymentNameMap,
40
40
  processResponsesStream,
@@ -56,7 +56,7 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?:
56
56
 
57
57
  // Azure OpenAI Responses-specific options
58
58
  export interface AzureOpenAIResponsesOptions extends StreamOptions {
59
- reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
59
+ reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
60
60
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
61
61
  azureApiVersion?: string;
62
62
  azureResourceName?: string;
@@ -348,7 +348,7 @@ function buildParams(
348
348
  model: deploymentName,
349
349
  input: messages,
350
350
  stream: true,
351
- prompt_cache_key: getOpenAIResponsesPromptCacheKey(options),
351
+ prompt_cache_key: getOpenAIPromptCacheKey(options),
352
352
  // Encrypted reasoning replay (applyResponsesReasoningParams) requires
353
353
  // stateless responses, matching the openai provider.
354
354
  store: false,
@@ -35,7 +35,7 @@ import { transformMessages } from "./transform-messages";
35
35
  import { joinTextWithImagePlaceholder, partitionVisionContent } from "./vision-guard";
36
36
 
37
37
  export interface OllamaChatOptions extends StreamOptions {
38
- reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
38
+ reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
39
39
  disableReasoning?: boolean;
40
40
  toolChoice?: ToolChoice;
41
41
  }