@oh-my-pi/pi-ai 18.2.8 → 18.2.9

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 (45) hide show
  1. package/CHANGELOG.md +33 -18
  2. package/dist/types/auth-gateway/types.d.ts +5 -0
  3. package/dist/types/auth-storage.d.ts +35 -32
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/providers/amazon-bedrock.d.ts +7 -0
  6. package/dist/types/providers/claude-code-fingerprint.d.ts +22 -3
  7. package/dist/types/providers/google-gemini-cli.d.ts +0 -2
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +2 -2
  9. package/dist/types/usage/claude-api.d.ts +22 -0
  10. package/dist/types/usage/claude-reset.d.ts +44 -0
  11. package/dist/types/usage.d.ts +111 -5
  12. package/dist/types/utils/schema/json-schema-validator.d.ts +5 -2
  13. package/dist/types/utils/tool-call-loop-guard.d.ts +1 -1
  14. package/package.json +6 -6
  15. package/src/auth/sqlite-credential-store.ts +44 -1
  16. package/src/auth-broker/remote-store.ts +6 -6
  17. package/src/auth-broker/wire-schemas.ts +14 -0
  18. package/src/auth-gateway/server.ts +4 -0
  19. package/src/auth-gateway/types.ts +5 -0
  20. package/src/auth-storage.ts +263 -140
  21. package/src/error/flags.ts +10 -0
  22. package/src/index.ts +1 -0
  23. package/src/providers/amazon-bedrock.ts +55 -5
  24. package/src/providers/anthropic.ts +45 -11
  25. package/src/providers/aws-credentials.ts +124 -11
  26. package/src/providers/claude-code-fingerprint.ts +55 -3
  27. package/src/providers/gitlab-duo.ts +20 -4
  28. package/src/providers/google-gemini-cli.ts +0 -8
  29. package/src/providers/google-shared.ts +1 -18
  30. package/src/providers/openai-chat-server-schema.ts +1 -1
  31. package/src/providers/openai-chat-server.ts +3 -1
  32. package/src/providers/openai-codex-responses.ts +27 -5
  33. package/src/providers/openai-completions.ts +122 -19
  34. package/src/providers/pi-native-server.ts +1 -0
  35. package/src/registry/oauth/anthropic.ts +2 -3
  36. package/src/stream.ts +13 -4
  37. package/src/usage/alibaba-token-plan.ts +7 -1
  38. package/src/usage/claude-api.ts +66 -0
  39. package/src/usage/claude-reset.ts +638 -0
  40. package/src/usage/claude.ts +37 -59
  41. package/src/usage/kimi.ts +32 -1
  42. package/src/usage.ts +52 -5
  43. package/src/utils/schema/json-schema-validator.ts +23 -10
  44. package/src/utils/tool-call-loop-guard.ts +2 -2
  45. package/src/utils/validation.ts +145 -50
@@ -8,6 +8,7 @@
8
8
  * - re-exported `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
+ import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
11
12
  import { planRequirementFor } from "@oh-my-pi/pi-catalog/compat/behavior";
12
13
  import { $env, $envExact, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
13
14
  import {
@@ -50,11 +51,14 @@ import type {
50
51
  UsageLogger,
51
52
  UsageProvider,
52
53
  UsageReport,
54
+ UsageResetCredit,
55
+ UsageResetCredits,
53
56
  } from "./usage";
54
57
  import { resolveUsedFraction } from "./usage";
55
58
  import { alibabaTokenPlanRankingStrategy, alibabaTokenPlanUsageProvider } from "./usage/alibaba-token-plan";
56
59
  import { charmHyperUsageProvider } from "./usage/charm-hyper";
57
60
  import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
61
+ import { consumeClaudeResetCredit, listClaudeResetCredits } from "./usage/claude-reset";
58
62
  import { clinePassUsageProvider } from "./usage/cline-pass";
59
63
  import { cursorUsageProvider } from "./usage/cursor";
60
64
  import { devinUsageProvider } from "./usage/devin";
@@ -66,13 +70,7 @@ import { museCodeUsageProvider } from "./usage/muse-code";
66
70
  import { minimaxCodeUsageProvider } from "./usage/minimax-code";
67
71
  import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama";
68
72
  import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
69
- import {
70
- type CodexResetConsumeCode,
71
- type CodexResetCredit,
72
- consumeCodexResetCredit,
73
- listCodexResetCredits,
74
- pickSoonestExpiringCredit,
75
- } from "./usage/openai-codex-reset";
73
+ import { consumeCodexResetCredit, listCodexResetCredits, pickSoonestExpiringCredit } from "./usage/openai-codex-reset";
76
74
  import { opencodeGoRankingStrategy, opencodeGoUsageProvider } from "./usage/opencode-go";
77
75
  import { syntheticUsageProvider } from "./usage/synthetic";
78
76
  import { umansUsageProvider } from "./usage/umans";
@@ -989,14 +987,15 @@ export interface StoredOAuthRefreshResult<T extends OAuthCredential = OAuthCrede
989
987
  removed: boolean;
990
988
  }
991
989
 
992
- /**
993
- * Identifies which stored account to redeem a saved rate-limit reset for.
994
- * Any one field is enough; `credentialId` is the most precise.
995
- */
990
+ /** A saved-reset option bound to one provider and durable stored credential. */
996
991
  export interface ResetCreditTarget {
997
- credentialId?: number;
992
+ provider: string;
993
+ credentialId: number;
994
+ /** Grant selected by the caller; a changed offer must be confirmed again. */
995
+ creditId?: string;
998
996
  accountId?: string;
999
997
  email?: string;
998
+ orgId?: string;
1000
999
  }
1001
1000
 
1002
1001
  /** Outcome of {@link AuthStorage.redeemResetCredit}. */
@@ -1011,21 +1010,28 @@ export interface ResetCreditRedeemOutcome {
1011
1010
  * retryable, unlike a genuine `no_credit`), `http_<status>` (unexpected
1012
1011
  * HTTP).
1013
1012
  */
1014
- code: CodexResetConsumeCode;
1013
+ code: string;
1014
+ provider?: string;
1015
1015
  accountId?: string;
1016
1016
  email?: string;
1017
+ orgId?: string;
1018
+ /** Provider explanation for an unavailable or refused reset. */
1019
+ reason?: string;
1020
+ /** Normalized usage limit IDs the provider confirmed it cleared. */
1021
+ cleared?: string[];
1017
1022
  /** The credit that was spent (when one was). */
1018
1023
  creditId?: string;
1019
1024
  }
1020
1025
 
1021
1026
  /** One stored account's live saved-reset status, from {@link AuthStorage.listResetCredits}. */
1022
- export interface ResetCreditAccountStatus {
1023
- credentialId?: number;
1027
+ export interface ResetCreditAccountStatus extends UsageResetCredits {
1028
+ provider: string;
1029
+ credentialId: number;
1024
1030
  accountId?: string;
1025
1031
  email?: string;
1026
- /** Resets redeemable for this account right now (live, not cached). */
1027
- availableCount: number;
1028
- credits: CodexResetCredit[];
1032
+ orgId?: string;
1033
+ orgName?: string;
1034
+ credits: UsageResetCredit[];
1029
1035
  /** Whether this is the given session's active account. */
1030
1036
  active: boolean;
1031
1037
  /** Set when the account's token refresh or list call failed. */
@@ -1398,6 +1404,13 @@ export class AuthStorage {
1398
1404
  #usageHeaderIngestAt: Map<string, number> = new Map();
1399
1405
  #usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
1400
1406
  #usageFetch: typeof fetch;
1407
+ /** Manual and automatic attempts on one stored account share a mutation. */
1408
+ #resetInFlight = new Map<string, { creditId?: string; promise: Promise<ResetCreditRedeemOutcome> }>();
1409
+ /** Ambiguous Claude claims retain their idempotency key until reconciled. */
1410
+ #pendingClaudeResets = new Map<
1411
+ string,
1412
+ { creditId: string; requestId: string; program?: string; remainingCount?: number; startedAt: number }
1413
+ >();
1401
1414
  #usageRequestTimeoutMs: number;
1402
1415
  #usageLogger?: UsageLogger;
1403
1416
  #fallbackResolver?: (provider: string) => string | undefined;
@@ -2952,16 +2965,57 @@ export class AuthStorage {
2952
2965
  return this.#getCredentialsForProvider(provider).length > 0;
2953
2966
  }
2954
2967
 
2968
+ /**
2969
+ * True when a stored credential is the provider's KDL `empty-fallback`
2970
+ * keyless-mode marker — what an empty paste at an "Optional: paste API key"
2971
+ * login prompt stores (e.g. `lm-studio-local` for lm-studio). The wire layer
2972
+ * never sends these as a bearer (`isDiscoveryBearerApiKey` strips them), so
2973
+ * auth-status surfaces must not count them either; otherwise the model hub
2974
+ * and `/login` report the provider as authenticated while every request
2975
+ * goes out bare (issue #12281). The credential itself stays stored: `/logout`
2976
+ * can still remove it, and availability treats the provider as keyless.
2977
+ */
2978
+ #isKeylessFallbackCredential(provider: string, credential: AuthCredential): boolean {
2979
+ if (credential.type !== "api_key") return false;
2980
+ const login = authPolicyFor(provider)?.login;
2981
+ if (login?.kind !== "api-key") return false;
2982
+ const fallback = login.emptyFallback;
2983
+ return fallback !== undefined && fallback !== "" && credential.key === fallback;
2984
+ }
2985
+
2986
+ /** Stored credentials that carry real auth — keyless-fallback markers excluded. */
2987
+ #getAuthBearingCredentials(provider: string): AuthCredential[] {
2988
+ return this.#getCredentialsForProvider(provider).filter(
2989
+ credential => !this.#isKeylessFallbackCredential(provider, credential),
2990
+ );
2991
+ }
2992
+
2993
+ /**
2994
+ * True when the provider has stored credentials but none of them carries
2995
+ * auth — i.e. its only credential is the KDL `empty-fallback` keyless-mode
2996
+ * marker (an empty paste at an optional-key login prompt). Such a provider
2997
+ * is configured-but-keyless: model availability treats it like an
2998
+ * `auth: none` endpoint instead of locking it out (issue #12281).
2999
+ */
3000
+ hasKeylessPlaceholder(provider: string): boolean {
3001
+ const stored = this.#getCredentialsForProvider(provider);
3002
+ return stored.length > 0 && stored.every(credential => this.#isKeylessFallbackCredential(provider, credential));
3003
+ }
3004
+
2955
3005
  /**
2956
3006
  * Dedicated auth for default-model availability (picker / `getAvailable`).
2957
3007
  * Unlike {@link getApiKey}, this does not refresh OAuth tokens, and unlike
2958
3008
  * {@link hasResolvableAuth} it ignores cross-provider env aliases so
2959
3009
  * `XAI_API_KEY` does not auto-select SuperGrok (`xai-oauth`).
3010
+ *
3011
+ * A stored keyless-fallback marker (empty paste at an optional-key login)
3012
+ * does not count: it never reaches the wire as a bearer, so treating it as
3013
+ * auth would present the provider as signed-in while requests go out bare.
2960
3014
  */
2961
3015
  hasAuth(provider: string): boolean {
2962
3016
  if (this.#runtimeOverrides.has(provider)) return true;
2963
3017
  if (this.#configOverrides.has(provider)) return true;
2964
- if (this.#getCredentialsForProvider(provider).length > 0) return true;
3018
+ if (this.#getAuthBearingCredentials(provider).length > 0) return true;
2965
3019
  if (this.#hasDedicatedEnvAuth(provider)) return true;
2966
3020
  if (this.#fallbackResolver?.(provider)) return true;
2967
3021
  return false;
@@ -2981,7 +3035,7 @@ export class AuthStorage {
2981
3035
  hasConcreteAuth(provider: string): boolean {
2982
3036
  if (this.#runtimeOverrides.has(provider)) return true;
2983
3037
  if (this.#configOverrides.has(provider)) return true;
2984
- if (this.#getCredentialsForProvider(provider).length > 0) return true;
3038
+ if (this.#getAuthBearingCredentials(provider).length > 0) return true;
2985
3039
  if ((provider === "amazon-bedrock" || provider === "bedrock-mantle") && $env.AWS_BEARER_TOKEN_BEDROCK?.trim()) {
2986
3040
  return true;
2987
3041
  }
@@ -3021,7 +3075,7 @@ export class AuthStorage {
3021
3075
  hasNonEnvCredential(provider: string): boolean {
3022
3076
  if (this.#runtimeOverrides.has(provider)) return true;
3023
3077
  if (this.#configOverrides.has(provider)) return true;
3024
- if (this.#getCredentialsForProvider(provider).length > 0) return true;
3078
+ if (this.#getAuthBearingCredentials(provider).length > 0) return true;
3025
3079
  if (this.#fallbackResolver?.(provider)) return true;
3026
3080
  return false;
3027
3081
  }
@@ -3053,7 +3107,7 @@ export class AuthStorage {
3053
3107
  getCredentialOrigin(provider: string): CredentialOrigin | undefined {
3054
3108
  if (this.#runtimeOverrides.has(provider)) return { kind: "runtime" };
3055
3109
  if (this.#configOverrides.has(provider)) return { kind: "config" };
3056
- const stored = this.#getCredentialsForProvider(provider);
3110
+ const stored = this.#getAuthBearingCredentials(provider);
3057
3111
  if (stored.some(credential => credential.type === "oauth")) return { kind: "oauth" };
3058
3112
  if (stored.some(credential => credential.type === "api_key" && credential.source === "login")) {
3059
3113
  return { kind: "api_key" };
@@ -5994,7 +6048,10 @@ export class AuthStorage {
5994
6048
  provider,
5995
6049
  "api_key",
5996
6050
  undefined,
5997
- credential => credential.type === "api_key" && credential.source === "login",
6051
+ credential =>
6052
+ credential.type === "api_key" &&
6053
+ credential.source === "login" &&
6054
+ !this.#isKeylessFallbackCredential(provider, credential),
5998
6055
  );
5999
6056
  if (loginApiKeySelection) {
6000
6057
  return this.#configValueResolver(loginApiKeySelection.credential.key);
@@ -6049,7 +6106,7 @@ export class AuthStorage {
6049
6106
  provider,
6050
6107
  sessionId,
6051
6108
  options,
6052
- credential => credential.source === "login",
6109
+ credential => credential.source === "login" && !this.#isKeylessFallbackCredential(provider, credential),
6053
6110
  );
6054
6111
  if (loginApiKeySelection) {
6055
6112
  this.#recordSessionCredential(provider, sessionId, "api_key", loginApiKeySelection.index);
@@ -6346,16 +6403,7 @@ export class AuthStorage {
6346
6403
  return this.#resolveStoredOAuthAccess(provider, selection, providerKey, options);
6347
6404
  }
6348
6405
 
6349
- /**
6350
- * List saved rate-limit resets for every stored OAuth account of `provider`
6351
- * (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route.
6352
- *
6353
- * This deliberately bypasses the usage-report cache: `/wham/usage` is
6354
- * IP-rate-limited and may serve stale (or pre-feature) snapshots when many
6355
- * accounts are polled, which would hide redeemable credits. One entry per
6356
- * account, with the session's active account flagged and unreachable
6357
- * accounts carrying an `error`.
6358
- */
6406
+ /** List live saved-reset balances and eligibility for one provider's stored OAuth accounts. */
6359
6407
  async listResetCredits(options?: {
6360
6408
  provider?: string;
6361
6409
  sessionId?: string;
@@ -6363,36 +6411,25 @@ export class AuthStorage {
6363
6411
  signal?: AbortSignal;
6364
6412
  }): Promise<ResetCreditAccountStatus[]> {
6365
6413
  const provider = options?.provider ?? "openai-codex";
6366
- const accesses = await this.getOAuthAccesses(provider);
6367
- if (accesses.length === 0) return [];
6414
+ if (provider !== "openai-codex" && provider !== "anthropic") return [];
6415
+ const accounts = this.listOAuthAccounts(provider, options?.sessionId);
6368
6416
  const baseUrl = options?.baseUrlResolver?.(provider);
6369
- const activeId = this.getOAuthAccountIdentity(provider, options?.sessionId);
6370
6417
  return Promise.all(
6371
- accesses.map(async (access): Promise<ResetCreditAccountStatus> => {
6372
- const active =
6373
- !!activeId &&
6374
- ((!!activeId.accountId && activeId.accountId === access.accountId) ||
6375
- (!!activeId.email && activeId.email === access.email));
6376
- const base = {
6377
- credentialId: access.credentialId,
6378
- accountId: access.accountId,
6379
- email: access.email,
6380
- active,
6381
- };
6382
- if (!access.ok)
6418
+ accounts.map(async (account): Promise<ResetCreditAccountStatus> => {
6419
+ const base = { ...account, provider };
6420
+ const access = await this.getOAuthAccessByCredentialId(provider, account.credentialId, {
6421
+ signal: options?.signal,
6422
+ });
6423
+ if (!access?.ok)
6383
6424
  return {
6384
6425
  ...base,
6385
6426
  availableCount: 0,
6386
6427
  credits: [],
6387
- error: access.error,
6428
+ error: access?.error ?? "Account no longer available",
6388
6429
  };
6389
- const list = await listCodexResetCredits({
6390
- accessToken: access.accessToken,
6391
- accountId: access.accountId,
6392
- baseUrl,
6393
- fetch: this.#usageFetch,
6394
- signal: options?.signal,
6395
- });
6430
+ const auth = { ...access, baseUrl, fetch: this.#usageFetch, signal: options?.signal };
6431
+ const list =
6432
+ provider === "anthropic" ? await listClaudeResetCredits(auth) : await listCodexResetCredits(auth);
6396
6433
  if (!list)
6397
6434
  return {
6398
6435
  ...base,
@@ -6400,115 +6437,195 @@ export class AuthStorage {
6400
6437
  credits: [],
6401
6438
  error: "Failed to load saved resets",
6402
6439
  };
6403
- return {
6404
- ...base,
6405
- availableCount: list.availableCount,
6406
- credits: list.credits,
6407
- };
6440
+ return { ...base, ...list };
6408
6441
  }),
6409
6442
  );
6410
6443
  }
6411
6444
 
6412
6445
  /**
6413
- * Redeem one saved rate-limit reset (OpenAI Codex "saved resets") for a
6414
- * specific stored account.
6415
- *
6416
- * Resolves a fresh access token for the target account, picks an available
6417
- * credit (the given `creditId`, else the first redeemable one), spends it,
6418
- * and invalidates the cached usage report so the next `/usage` reflects the
6419
- * reset. Never throws for business outcomes — inspect the returned `code`.
6446
+ * Redeem a stored account's saved reset after checking its live offer.
6447
+ * Business refusals return a code; transport errors may throw without losing Claude's request ID.
6420
6448
  */
6421
6449
  async redeemResetCredit(options: {
6422
6450
  target: ResetCreditTarget;
6423
- provider?: string;
6424
- creditId?: string;
6425
6451
  baseUrlResolver?: (provider: string) => string | undefined;
6426
6452
  signal?: AbortSignal;
6427
6453
  }): Promise<ResetCreditRedeemOutcome> {
6428
- const provider = options.provider ?? "openai-codex";
6429
- const baseUrl = options.baseUrlResolver?.(provider);
6430
6454
  const { target } = options;
6431
- const accesses = await this.getOAuthAccesses(provider);
6432
- const match = accesses.find(
6433
- access =>
6434
- (target.credentialId !== undefined && access.credentialId === target.credentialId) ||
6435
- (!!target.accountId && access.accountId === target.accountId) ||
6436
- (!!target.email && access.email === target.email),
6437
- );
6438
- if (!match)
6439
- return {
6440
- ok: false,
6441
- code: "no_account",
6442
- accountId: target.accountId,
6443
- email: target.email,
6444
- };
6445
- if (!match.ok) {
6446
- return {
6447
- ok: false,
6448
- code: "account_unavailable",
6449
- accountId: match.accountId,
6450
- email: match.email,
6451
- };
6455
+ const { provider, creditId } = target;
6456
+ const identity = { provider, accountId: target.accountId, email: target.email, orgId: target.orgId };
6457
+ if (provider !== "openai-codex" && provider !== "anthropic") {
6458
+ return { ...identity, ok: false, code: "unsupported_provider" };
6452
6459
  }
6460
+ const baseUrl = options.baseUrlResolver?.(provider);
6461
+ const match = await this.getOAuthAccessByCredentialId(provider, target.credentialId, { signal: options.signal });
6462
+ if (!match) return { ...identity, ok: false, code: "no_account" };
6463
+ const resolvedIdentity = {
6464
+ provider,
6465
+ accountId: match.accountId,
6466
+ email: match.email,
6467
+ orgId: match.orgId,
6468
+ };
6469
+ if (!match.ok) return { ...resolvedIdentity, ok: false, code: "account_unavailable" };
6470
+ const accountKey = JSON.stringify([provider, baseUrl, target.credentialId]);
6471
+ const inFlight = this.#resetInFlight.get(accountKey);
6472
+ if (inFlight) {
6473
+ if (inFlight.creditId !== creditId) return { ...resolvedIdentity, ok: false, code: "reset_in_progress" };
6474
+ return inFlight.promise;
6475
+ }
6476
+ const promise = this.#redeemAccountReset(provider, match, accountKey, {
6477
+ creditId,
6478
+ baseUrl,
6479
+ signal: options.signal,
6480
+ }).finally(() => this.#resetInFlight.delete(accountKey));
6481
+ this.#resetInFlight.set(accountKey, { creditId, promise });
6482
+ return promise;
6483
+ }
6453
6484
 
6485
+ async #redeemAccountReset(
6486
+ provider: string,
6487
+ access: OAuthAccess,
6488
+ accountKey: string,
6489
+ options: { creditId?: string; baseUrl?: string; signal?: AbortSignal },
6490
+ ): Promise<ResetCreditRedeemOutcome> {
6491
+ const identity = { provider, accountId: access.accountId, email: access.email, orgId: access.orgId };
6492
+ const auth = { ...access, baseUrl: options.baseUrl, fetch: this.#usageFetch, signal: options.signal };
6454
6493
  let creditId = options.creditId;
6455
- if (!creditId) {
6456
- const list = await listCodexResetCredits({
6457
- accessToken: match.accessToken,
6458
- accountId: match.accountId,
6459
- baseUrl,
6460
- fetch: this.#usageFetch,
6461
- signal: options.signal,
6462
- });
6463
- // Transport/auth failure is NOT "no credits": callers treat `no_credit`
6464
- // as terminal for the episode, so conflating them would bury a live
6465
- // credit behind one flaky request.
6466
- if (!list) {
6467
- return {
6468
- ok: false,
6469
- code: "credit_list_failed",
6470
- accountId: match.accountId,
6471
- email: match.email,
6472
- };
6494
+ let result: ResetCreditRedeemOutcome;
6495
+ let report: UsageReport | null = null;
6496
+ if (provider === "anthropic") {
6497
+ const list = await listClaudeResetCredits(auth);
6498
+ if (!list) return { ...identity, ok: false, code: "credit_list_failed" };
6499
+ const selected = list.credits.find(credit => credit.id === list.nextCreditId);
6500
+ if (creditId && creditId !== list.nextCreditId) {
6501
+ return { ...identity, ok: false, code: "offer_changed", creditId };
6473
6502
  }
6474
- const credit = pickSoonestExpiringCredit(list.credits);
6475
- if (!credit)
6503
+ if (!list.eligible || !selected || !selected.usable || (list.redeemableCount ?? 0) < 1) {
6476
6504
  return {
6505
+ ...identity,
6477
6506
  ok: false,
6478
- code: "no_credit",
6479
- accountId: match.accountId,
6480
- email: match.email,
6507
+ code: list.availableCount > 0 ? "ineligible" : "no_credit",
6508
+ reason: list.reason,
6481
6509
  };
6482
- creditId = credit.id;
6510
+ }
6511
+ creditId = selected.id;
6512
+ let pending = this.#pendingClaudeResets.get(accountKey);
6513
+ if (pending && Date.now() - pending.startedAt >= 10 * 60_000) {
6514
+ this.#pendingClaudeResets.delete(accountKey);
6515
+ pending = undefined;
6516
+ }
6517
+ if (pending) {
6518
+ if (pending.creditId !== creditId) return { ...identity, ok: false, code: "reset_unconfirmed", creditId };
6519
+ if (
6520
+ pending.remainingCount !== undefined &&
6521
+ selected.remainingCount !== undefined &&
6522
+ selected.remainingCount < pending.remainingCount
6523
+ ) {
6524
+ this.#pendingClaudeResets.delete(accountKey);
6525
+ this.#invalidateUsageReportCache(provider, options.baseUrl);
6526
+ return { ...identity, ok: false, code: "already_redeemed", creditId };
6527
+ }
6528
+ if (pending.program === "juniper_tide") {
6529
+ return { ...identity, ok: false, code: "reset_unconfirmed", creditId };
6530
+ }
6531
+ }
6532
+ const credential = this.#getStoredCredentials(provider).find(entry => entry.id === access.credentialId);
6533
+ if (credential?.credential.type === "oauth") {
6534
+ report = await this.#getUsageReport(provider, credential.credential, {
6535
+ baseUrl: options.baseUrl,
6536
+ signal: options.signal,
6537
+ });
6538
+ }
6539
+ const requestId = pending?.requestId ?? crypto.randomUUID();
6540
+ options.signal?.throwIfAborted();
6541
+ this.#pendingClaudeResets.set(accountKey, {
6542
+ creditId,
6543
+ requestId,
6544
+ program: selected.program,
6545
+ remainingCount: selected.remainingCount,
6546
+ startedAt: pending?.startedAt ?? Date.now(),
6547
+ });
6548
+ const consumed = await consumeClaudeResetCredit({
6549
+ ...auth,
6550
+ baseUrl: list.baseUrl ?? auth.baseUrl,
6551
+ orgId: list.orgId ?? access.orgId,
6552
+ credit: selected,
6553
+ redeemRequestId: requestId,
6554
+ });
6555
+ if (
6556
+ consumed.ok ||
6557
+ consumed.code === "already_redeemed" ||
6558
+ consumed.code === "nothing_to_reset" ||
6559
+ consumed.code === "ineligible" ||
6560
+ (!pending &&
6561
+ (consumed.code === "cooldown" ||
6562
+ consumed.status === 401 ||
6563
+ consumed.status === 403 ||
6564
+ consumed.status === 429))
6565
+ ) {
6566
+ this.#pendingClaudeResets.delete(accountKey);
6567
+ }
6568
+ result = {
6569
+ ...identity,
6570
+ ok: consumed.ok,
6571
+ code: consumed.code,
6572
+ reason: consumed.reason,
6573
+ cleared: consumed.cleared,
6574
+ creditId,
6575
+ };
6576
+ } else {
6577
+ if (!creditId) {
6578
+ const list = await listCodexResetCredits(auth);
6579
+ if (!list) return { ...identity, ok: false, code: "credit_list_failed" };
6580
+ const credit = pickSoonestExpiringCredit(list.credits);
6581
+ if (!credit) return { ...identity, ok: false, code: "no_credit" };
6582
+ creditId = credit.id;
6583
+ }
6584
+ const consumed = await consumeCodexResetCredit({ ...auth, creditId });
6585
+ result = { ...identity, ok: consumed.ok, code: consumed.code, creditId };
6483
6586
  }
6484
-
6485
- const result = await consumeCodexResetCredit({
6486
- creditId,
6487
- accessToken: match.accessToken,
6488
- accountId: match.accountId,
6489
- baseUrl,
6490
- fetch: this.#usageFetch,
6491
- signal: options.signal,
6492
- });
6493
6587
  if (result.ok) {
6494
- this.#invalidateUsageReportCache(provider, baseUrl);
6588
+ this.#invalidateUsageReportCache(provider, options.baseUrl);
6495
6589
  if (this.#store.invalidateUsageCache) {
6496
6590
  await this.#store.invalidateUsageCache(options.signal).catch(err => {
6497
6591
  logger.debug("Failed to notify store of stale usage", { err });
6498
6592
  });
6499
6593
  }
6500
- // The window this credential was blocked on (by markUsageLimitReached)
6501
- // is now reset, so lift its temporary block — otherwise selection
6502
- // keeps skipping/under-ranking the freshly-reset account.
6503
- if (match.credentialId !== undefined) this.#clearCredentialBlocks(provider, match.credentialId);
6594
+ if (access.credentialId !== undefined) {
6595
+ if (provider === "anthropic")
6596
+ this.#clearClaudeResetBlocks(access.credentialId, result.cleared ?? [], report);
6597
+ else this.#clearCredentialBlocks(provider, access.credentialId);
6598
+ }
6599
+ }
6600
+ return result;
6601
+ }
6602
+
6603
+ /** Partial Claude resets must leave blocks for uncovered weekly/model limits intact. */
6604
+ #clearClaudeResetBlocks(credentialId: number, cleared: readonly string[], report: UsageReport | null): void {
6605
+ if (!report || Date.now() - report.fetchedAt > USAGE_REPORT_TTL_MS || cleared.length === 0) return;
6606
+ const provider = "anthropic";
6607
+ const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
6608
+ if (index < 0) return;
6609
+ const shared = report.limits.filter(limit => limit.scope.shared);
6610
+ if (!["anthropic:5h", "anthropic:7d"].every(id => shared.some(limit => limit.id === id))) return;
6611
+ const unscoped = report.limits.filter(
6612
+ limit => limit.scope.shared || limit.scope.tier === "opus" || limit.scope.tier === "sonnet",
6613
+ );
6614
+ const scopes = [
6615
+ { blockScope: undefined, limits: unscoped },
6616
+ ...(claudeRankingStrategy.healableBlockScopes?.(report) ?? []),
6617
+ ];
6618
+ for (const scope of scopes) {
6619
+ if (!scope.limits.some(limit => cleared.includes(limit.id))) continue;
6620
+ if (this.#isUsageLimitReached(scope.limits.filter(limit => !cleared.includes(limit.id)))) continue;
6621
+ this.#clearCredentialBlockScope(
6622
+ provider,
6623
+ credentialId,
6624
+ index,
6625
+ this.#getProviderTypeKey(provider, "oauth"),
6626
+ scope.blockScope,
6627
+ );
6504
6628
  }
6505
- return {
6506
- ok: result.ok,
6507
- code: result.code,
6508
- accountId: match.accountId,
6509
- email: match.email,
6510
- creditId,
6511
- };
6512
6629
  }
6513
6630
 
6514
6631
  /**
@@ -7305,6 +7422,9 @@ export class AuthStorage {
7305
7422
  throw error;
7306
7423
  }
7307
7424
  const updated: OAuthCredential = {
7425
+ // Preserve credential-subtype metadata, such as MCP token endpoints,
7426
+ // that the provider's bare OAuth response cannot reproduce.
7427
+ ...attempted,
7308
7428
  type: "oauth",
7309
7429
  access: refreshed.access,
7310
7430
  refresh: refreshed.refresh,
@@ -7493,7 +7613,10 @@ export class AuthStorage {
7493
7613
  if (oauthSource) return oauthSource;
7494
7614
  const loginApiKeySource = describeStored(
7495
7615
  "api_key",
7496
- credential => credential.type === "api_key" && credential.source === "login",
7616
+ credential =>
7617
+ credential.type === "api_key" &&
7618
+ credential.source === "login" &&
7619
+ !this.#isKeylessFallbackCredential(provider, credential),
7497
7620
  );
7498
7621
  if (loginApiKeySource) return loginApiKeySource;
7499
7622
  if (getEnvApiKey(provider)) return `env (over ${baseLabel})`;
@@ -275,6 +275,15 @@ const STRUCTURED_OUTPUTS_PATTERN = /structured[_ -]?outputs?/i;
275
275
  const FEATURE_NOT_SUPPORTED_PATTERN = /not (?:supported|available|enabled)|unsupported|does(?: not|n'?t) support/i;
276
276
  const ANTHROPIC_STRICT_FIELD_PATTERN = /\btools\.\d+\.custom\.strict\b/i;
277
277
  const EXTRA_INPUTS_NOT_PERMITTED_PATTERN = /extra inputs? (?:are|is) not permitted/i;
278
+ // Upstream strict-schema validation surfaced through a translating gateway.
279
+ // Vercel AI Gateway serves non-Anthropic upstreams on its Anthropic
280
+ // `/v1/messages` route and applies Anthropic's `strict: true` to an OpenAI
281
+ // function tool. OpenAI strict mode additionally demands every `properties`
282
+ // key in `required`, which Anthropic's strict subset does not, so a
283
+ // legally-optional parameter is rejected only after translation and the sole
284
+ // recovery is dropping `strict`. Mirrors the phrasings
285
+ // `shouldRetryWithoutStrictTools` already recognizes on the OpenAI-family path.
286
+ const STRICT_TOOL_SCHEMA_REJECTION_PATTERN = /invalid schema for function|invalid tool parameters schema/i;
278
287
  // Anthropic fast-mode unsupported: 400 rejecting `speed`, or 429 rate_limit_error
279
288
  // because the account lacks the extra-usage entitlement fast mode requires.
280
289
  const FAST_MODE_SPEED_PARAM_PATTERN = /\bspeed\b/i;
@@ -294,6 +303,7 @@ function matchesStrictToolsRejection(message: string, errorStatus: number | unde
294
303
  return true;
295
304
  }
296
305
  if (STRUCTURED_OUTPUTS_PATTERN.test(message) && FEATURE_NOT_SUPPORTED_PATTERN.test(message)) return true;
306
+ if (STRICT_TOOL_SCHEMA_REJECTION_PATTERN.test(message)) return true;
297
307
  if (!INVALID_REQUEST_PATTERN.test(message)) return false;
298
308
  const grammarTooLarge = GRAMMAR_TOO_LARGE_PATTERN.test(message) && GRAMMAR_TOO_LARGE_DETAIL_PATTERN.test(message);
299
309
  const schemaTooComplex =
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export * from "./stream";
40
40
  export * from "./types";
41
41
  export * from "./usage";
42
42
  export * from "./usage/claude";
43
+ export * from "./usage/claude-reset";
43
44
  export * from "./usage/cursor";
44
45
  export * from "./usage/gemini";
45
46
  export * from "./usage/github-copilot";