@oh-my-pi/pi-ai 16.3.11 → 16.3.13

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 (36) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/types/auth-broker/discover.d.ts +1 -1
  3. package/dist/types/auth-retry.d.ts +3 -1
  4. package/dist/types/auth-storage.d.ts +13 -8
  5. package/dist/types/providers/anthropic.d.ts +1 -0
  6. package/dist/types/providers/cursor.d.ts +5 -0
  7. package/dist/types/providers/openai-shared.d.ts +6 -3
  8. package/dist/types/providers/register-builtins.d.ts +5 -0
  9. package/dist/types/registry/oauth/callback-server.d.ts +2 -0
  10. package/dist/types/registry/oauth/xai-oauth.d.ts +1 -7
  11. package/dist/types/registry/registry.d.ts +2 -1
  12. package/dist/types/registry/xai-oauth.d.ts +2 -1
  13. package/dist/types/types.d.ts +2 -0
  14. package/dist/types/utils/event-stream.d.ts +7 -0
  15. package/dist/types/utils/idle-iterator.d.ts +10 -0
  16. package/package.json +4 -4
  17. package/src/auth-broker/discover.ts +20 -16
  18. package/src/auth-broker/remote-store.ts +55 -5
  19. package/src/auth-gateway/server.ts +1 -0
  20. package/src/auth-retry.ts +7 -2
  21. package/src/auth-storage.ts +210 -46
  22. package/src/providers/anthropic.ts +78 -8
  23. package/src/providers/cursor.ts +17 -10
  24. package/src/providers/openai-codex-responses.ts +37 -6
  25. package/src/providers/openai-responses.ts +1 -1
  26. package/src/providers/openai-shared.ts +30 -14
  27. package/src/providers/register-builtins.ts +15 -0
  28. package/src/registry/oauth/__tests__/xai-oauth.test.ts +55 -0
  29. package/src/registry/oauth/callback-server.ts +40 -12
  30. package/src/registry/oauth/xai-oauth.ts +6 -10
  31. package/src/registry/xai-oauth.ts +2 -1
  32. package/src/stream.ts +7 -1
  33. package/src/types.ts +2 -0
  34. package/src/usage/openai-codex.ts +3 -2
  35. package/src/utils/event-stream.ts +26 -0
  36. package/src/utils/idle-iterator.ts +65 -9
@@ -66,6 +66,7 @@ const USAGE_RANKING_METRIC_EPSILON = 1e-9;
66
66
  export type ApiKeyCredential = {
67
67
  type: "api_key";
68
68
  key: string;
69
+ source?: "login";
69
70
  };
70
71
 
71
72
  export type OAuthCredential = {
@@ -967,6 +968,7 @@ export class AuthStorage {
967
968
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
968
969
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
969
970
  #usageCache: UsageCache;
971
+ #usageCacheEpoch = 0;
970
972
  #usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
971
973
  #usageHeaderIngestAt: Map<string, number> = new Map();
972
974
  #usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
@@ -1390,12 +1392,14 @@ export class AuthStorage {
1390
1392
 
1391
1393
  const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
1392
1394
  if (credentialId === undefined) return blockedUntil;
1393
- const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
1394
- if (
1395
- persistedGlobalBlockedUntil !== undefined &&
1396
- (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
1397
- ) {
1398
- blockedUntil = persistedGlobalBlockedUntil;
1395
+ if (!blockScope || provider !== "openai-codex") {
1396
+ const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
1397
+ if (
1398
+ persistedGlobalBlockedUntil !== undefined &&
1399
+ (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
1400
+ ) {
1401
+ blockedUntil = persistedGlobalBlockedUntil;
1402
+ }
1399
1403
  }
1400
1404
  if (blockScope) {
1401
1405
  const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, blockScope);
@@ -1433,6 +1437,7 @@ export class AuthStorage {
1433
1437
  const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1434
1438
  backoffMap.set(credentialIndex, nextBlockedUntil);
1435
1439
  this.#credentialBackoff.set(backoffKey, backoffMap);
1440
+ this.#invalidateUsageReportCache(provider);
1436
1441
 
1437
1442
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
1438
1443
  if (!upsertCredentialBlock) return;
@@ -1554,13 +1559,14 @@ export class AuthStorage {
1554
1559
  provider: string,
1555
1560
  type: T,
1556
1561
  sessionId?: string,
1562
+ filter?: (credential: AuthCredential) => boolean,
1557
1563
  ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
1558
1564
  const credentials = this.#getCredentialsForProvider(provider)
1559
1565
  .map((credential, index) => ({ credential, index }))
1560
- .filter(
1561
- (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
1562
- entry.credential.type === type,
1563
- );
1566
+ .filter((entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } => {
1567
+ if (entry.credential.type !== type) return false;
1568
+ return filter?.(entry.credential) ?? true;
1569
+ });
1564
1570
 
1565
1571
  if (credentials.length === 0) return undefined;
1566
1572
  if (credentials.length === 1) return credentials[0];
@@ -1851,8 +1857,8 @@ export class AuthStorage {
1851
1857
  /**
1852
1858
  * Classify where a provider's auth comes from, following the same precedence
1853
1859
  * as {@link AuthStorage.getApiKey}: runtime override → config override →
1854
- * stored OAuth → env var → stored api_key → fallback resolver. Returns
1855
- * undefined when no auth is configured.
1860
+ * stored OAuth → login-stored api_key → env var → stored api_key →
1861
+ * fallback resolver. Returns undefined when no auth is configured.
1856
1862
  *
1857
1863
  * Compact, structured counterpart to {@link describeCredentialSource}.
1858
1864
  */
@@ -1861,6 +1867,9 @@ export class AuthStorage {
1861
1867
  if (this.#configOverrides.has(provider)) return { kind: "config" };
1862
1868
  const stored = this.#getCredentialsForProvider(provider);
1863
1869
  if (stored.some(credential => credential.type === "oauth")) return { kind: "oauth" };
1870
+ if (stored.some(credential => credential.type === "api_key" && credential.source === "login")) {
1871
+ return { kind: "api_key" };
1872
+ }
1864
1873
  if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
1865
1874
  if (stored.some(credential => credential.type === "api_key")) return { kind: "api_key" };
1866
1875
  if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
@@ -2005,7 +2014,7 @@ export class AuthStorage {
2005
2014
  if (!result) {
2006
2015
  return;
2007
2016
  }
2008
- const newCredential: ApiKeyCredential = { type: "api_key", key: result };
2017
+ const newCredential: ApiKeyCredential = { type: "api_key", key: result, source: "login" };
2009
2018
  const stored = this.#store.upsertAuthCredentialRemote
2010
2019
  ? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
2011
2020
  : this.#store.upsertAuthCredentialForProvider(provider, newCredential);
@@ -2336,8 +2345,10 @@ export class AuthStorage {
2336
2345
  const inFlight = this.#usageRequestInFlight.get(cacheKey);
2337
2346
  if (inFlight) return inFlight;
2338
2347
 
2348
+ const usageCacheEpoch = this.#usageCacheEpoch;
2339
2349
  const promise = (async () => {
2340
2350
  const report = await this.#fetchUsageUncached(request, timeoutMs);
2351
+ if (usageCacheEpoch !== this.#usageCacheEpoch) return report;
2341
2352
  const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
2342
2353
  if (report !== null) {
2343
2354
  // Success: stagger per-credential cache expiry so all accounts don't
@@ -2347,6 +2358,7 @@ export class AuthStorage {
2347
2358
  // times decorrelate within a few cycles.
2348
2359
  this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
2349
2360
  this.#recordUsageHistory(request, report);
2361
+ this.#reconcileCodexUsageBlock(request, report);
2350
2362
  return report;
2351
2363
  }
2352
2364
  // Failure: apply a short jittered cool-down so the credential doesn't
@@ -2761,7 +2773,14 @@ export class AuthStorage {
2761
2773
  // whole point of routing through it.
2762
2774
  const storeHook = this.#store.getUsageReport?.bind(this.#store);
2763
2775
  if (storeHook) {
2764
- return storeHook(provider, credential, options?.signal);
2776
+ const report = await storeHook(provider, credential, options?.signal);
2777
+ if (report) {
2778
+ this.#reconcileCodexUsageBlock(
2779
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2780
+ report,
2781
+ );
2782
+ }
2783
+ return report;
2765
2784
  }
2766
2785
  return this.#fetchUsageCached(
2767
2786
  this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
@@ -2789,7 +2808,10 @@ export class AuthStorage {
2789
2808
  // `RemoteAuthCredentialStore` implements the store hook so a gateway
2790
2809
  // backed by a broker automatically routes usage to the broker without
2791
2810
  // needing the caller to wire it explicitly.
2792
- const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
2811
+ const storeOverride = this.#store.fetchUsageReports?.bind(this.#store);
2812
+ const override = this.#fetchUsageReportsOverride ?? storeOverride;
2813
+ const shouldReconcileStoreHookReports =
2814
+ this.#fetchUsageReportsOverride === undefined && storeOverride !== undefined;
2793
2815
  if (override) {
2794
2816
  // Reuse the in-flight map so concurrent callers (widget poll + format
2795
2817
  // dispatch + credential selection) coalesce into one upstream call.
@@ -2805,7 +2827,9 @@ export class AuthStorage {
2805
2827
  });
2806
2828
  this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
2807
2829
  }
2808
- return raceUsageWithSignal(shared, options?.signal);
2830
+ const reports = await raceUsageWithSignal(shared, options?.signal);
2831
+ if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
2832
+ return reports;
2809
2833
  }
2810
2834
  if (!this.#usageProviderResolver) return null;
2811
2835
 
@@ -3057,9 +3081,20 @@ export class AuthStorage {
3057
3081
  async markUsageLimitReached(
3058
3082
  provider: string,
3059
3083
  sessionId: string | undefined,
3060
- options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; signal?: AbortSignal },
3084
+ options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; apiKey?: string; signal?: AbortSignal },
3061
3085
  ): Promise<UsageLimitMarkResult> {
3062
- const sessionCredential = this.#getSessionCredential(provider, sessionId);
3086
+ let sessionCredential: { type: AuthCredential["type"]; index: number } | undefined;
3087
+ if (options?.apiKey) {
3088
+ const stored = this.#getStoredCredentials(provider);
3089
+ for (let index = 0; index < stored.length; index++) {
3090
+ const entry = stored[index];
3091
+ if (entry && (await this.#credentialMatchesApiKey(entry.credential, options.apiKey))) {
3092
+ sessionCredential = { type: entry.credential.type, index };
3093
+ break;
3094
+ }
3095
+ }
3096
+ }
3097
+ sessionCredential ??= this.#getSessionCredential(provider, sessionId);
3063
3098
  if (!sessionCredential) return { switched: false };
3064
3099
 
3065
3100
  const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
@@ -3387,12 +3422,21 @@ export class AuthStorage {
3387
3422
  const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
3388
3423
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
3389
3424
  const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
3425
+ const sessionPreferredCredential =
3426
+ sessionPreferredIndex !== undefined
3427
+ ? credentials.find(entry => entry.index === sessionPreferredIndex)?.credential
3428
+ : undefined;
3429
+ const sessionPreferredCanRefreshOrUse =
3430
+ sessionPreferredCredential !== undefined &&
3431
+ (sessionPreferredCredential.refresh.trim().length > 0 ||
3432
+ Date.now() + OAUTH_REFRESH_SKEW_MS < sessionPreferredCredential.expires);
3390
3433
  // Skip ranking only when the session already has a working preferred credential — re-ranking
3391
3434
  // mid-session causes account switches that cold-start the server-side prompt cache. New sessions
3392
3435
  // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
3393
3436
  // with the most headroom proactively and fall back intelligently when rate-limited.
3394
3437
  const sessionPreferredIsAvailable =
3395
3438
  sessionPreferredIndex !== undefined &&
3439
+ sessionPreferredCanRefreshOrUse &&
3396
3440
  !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
3397
3441
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
3398
3442
  const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
@@ -3899,8 +3943,8 @@ export class AuthStorage {
3899
3943
  return configKey;
3900
3944
  }
3901
3945
 
3902
- // Precedence: a deliberate OAuth login wins, then an explicit env var, then a stored
3903
- // static api_key (which may be a stale broker-migrated copy) as a last resort.
3946
+ // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
3947
+ // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
3904
3948
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
3905
3949
  if (oauthSelection) {
3906
3950
  const expiresAt = oauthSelection.credential.expires;
@@ -3916,6 +3960,16 @@ export class AuthStorage {
3916
3960
  }
3917
3961
  }
3918
3962
 
3963
+ const loginApiKeySelection = this.#selectCredentialByType(
3964
+ provider,
3965
+ "api_key",
3966
+ undefined,
3967
+ credential => credential.type === "api_key" && credential.source === "login",
3968
+ );
3969
+ if (loginApiKeySelection) {
3970
+ return this.#configValueResolver(loginApiKeySelection.credential.key);
3971
+ }
3972
+
3919
3973
  const envKey = getEnvApiKey(provider);
3920
3974
  if (envKey) return envKey;
3921
3975
 
@@ -3933,9 +3987,10 @@ export class AuthStorage {
3933
3987
  * 1. Runtime override (CLI --api-key)
3934
3988
  * 2. Config override (models.yml `providers.<name>.apiKey`)
3935
3989
  * 3. OAuth token from storage (auto-refreshed)
3936
- * 4. Environment variable
3937
- * 5. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
3938
- * 6. Fallback resolver (models.yml custom providers, last-resort)
3990
+ * 4. API key persisted by a successful `/login`
3991
+ * 5. Environment variable
3992
+ * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
3993
+ * 7. Fallback resolver (models.yml custom providers, last-resort)
3939
3994
  */
3940
3995
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
3941
3996
  // Runtime override takes highest priority
@@ -3954,13 +4009,24 @@ export class AuthStorage {
3954
4009
  return configKey;
3955
4010
  }
3956
4011
 
3957
- // Precedence: a deliberate OAuth login wins, then an explicit env var, then a stored
3958
- // static api_key (which may be a stale broker-migrated copy) as a last resort.
4012
+ // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
4013
+ // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
3959
4014
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
3960
4015
  if (oauthResolved) {
3961
4016
  return oauthResolved.apiKey;
3962
4017
  }
3963
4018
 
4019
+ const loginApiKeySelection = this.#selectCredentialByType(
4020
+ provider,
4021
+ "api_key",
4022
+ sessionId,
4023
+ credential => credential.type === "api_key" && credential.source === "login",
4024
+ );
4025
+ if (loginApiKeySelection) {
4026
+ this.#recordSessionCredential(provider, sessionId, "api_key", loginApiKeySelection.index);
4027
+ return this.#configValueResolver(loginApiKeySelection.credential.key);
4028
+ }
4029
+
3964
4030
  // Past OAuth: the session sticky (if any) is stale — the request authenticates via
3965
4031
  // env/api_key/fallback, not OAuth, so clear it now so getOAuthAccountId() correctly
3966
4032
  // suppresses account_uuid for this session.
@@ -3969,7 +4035,12 @@ export class AuthStorage {
3969
4035
  const envKey = getEnvApiKey(provider);
3970
4036
  if (envKey) return envKey;
3971
4037
 
3972
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
4038
+ const apiKeySelection = this.#selectCredentialByType(
4039
+ provider,
4040
+ "api_key",
4041
+ sessionId,
4042
+ credential => credential.type !== "api_key" || credential.source !== "login",
4043
+ );
3973
4044
  if (apiKeySelection) {
3974
4045
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3975
4046
  return this.#configValueResolver(apiKeySelection.credential.key);
@@ -4255,6 +4326,7 @@ export class AuthStorage {
4255
4326
  * `/usage` reflects a freshly-redeemed reset instead of stale numbers.
4256
4327
  */
4257
4328
  #invalidateUsageReportCache(provider: string, baseUrl?: string): void {
4329
+ this.#usageCacheEpoch += 1;
4258
4330
  const expired = Date.now() - 1;
4259
4331
  for (const entry of this.#getStoredCredentials(provider)) {
4260
4332
  if (entry.credential.type !== "oauth") continue;
@@ -4266,6 +4338,12 @@ export class AuthStorage {
4266
4338
  }
4267
4339
  }
4268
4340
 
4341
+ #invalidateUsageReportCacheForProviderKey(providerKey: string): void {
4342
+ const oauthSuffix = ":oauth";
4343
+ if (!providerKey.endsWith(oauthSuffix)) return;
4344
+ this.#invalidateUsageReportCache(providerKey.slice(0, -oauthSuffix.length));
4345
+ }
4346
+
4269
4347
  /**
4270
4348
  * Lift any temporary backoff blocks on one credential (across the bare
4271
4349
  * `provider:oauth` key and its scoped `\0`-suffixed derivatives). Called
@@ -4274,13 +4352,10 @@ export class AuthStorage {
4274
4352
  * that `markUsageLimitReached` set for the now-obsolete reset time.
4275
4353
  */
4276
4354
  #clearCredentialBlocks(provider: string, credentialId: number): void {
4277
- const deleteCredentialBlocks = this.#store.deleteCredentialBlocks?.bind(this.#store);
4278
- if (deleteCredentialBlocks) {
4279
- try {
4280
- deleteCredentialBlocks(credentialId);
4281
- } catch (err) {
4282
- logger.debug("Failed to clear persisted credential blocks", { err, provider, credentialId });
4283
- }
4355
+ try {
4356
+ this.deleteCredentialBlocks(credentialId);
4357
+ } catch (err) {
4358
+ logger.debug("Failed to clear persisted credential blocks", { err, provider, credentialId });
4284
4359
  }
4285
4360
 
4286
4361
  const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
@@ -4294,6 +4369,80 @@ export class AuthStorage {
4294
4369
  }
4295
4370
  }
4296
4371
 
4372
+ /**
4373
+ * 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.
4378
+ */
4379
+ #isHealthyCodexUsageReport(report: UsageReport): boolean {
4380
+ 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
+ );
4387
+ }
4388
+
4389
+ #reconcileCodexUsageBlockForCredential(provider: Provider, credentialId: number, report: UsageReport): void {
4390
+ if (!this.#isHealthyCodexUsageReport(report)) return;
4391
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
4392
+ const credentialIndex = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
4393
+ if (credentialIndex < 0) return;
4394
+ // Mirror selection: consult the same strategy scope `markUsageLimitReached`
4395
+ // persists under, else a scoped block is invisible here and never healed.
4396
+ const blockScope = this.#rankingStrategyResolver?.(provider)?.blockScope?.({});
4397
+ const blockedUntilMs = this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope);
4398
+ if (blockedUntilMs === undefined) return;
4399
+ this.#clearCredentialBlocks(provider, credentialId);
4400
+ logger.info("Cleared stale Codex usage-limit block after healthy live usage report", {
4401
+ credentialId,
4402
+ provider,
4403
+ clearedBlockedUntilMs: blockedUntilMs,
4404
+ });
4405
+ }
4406
+
4407
+ #reconcileCodexUsageBlock(request: UsageRequestDescriptor, report: UsageReport): void {
4408
+ if (request.provider !== "openai-codex") return;
4409
+ const credentialId = this.#findStoredCredentialIdForUsageCredential(request.provider, request.credential);
4410
+ if (credentialId === undefined) return;
4411
+ this.#reconcileCodexUsageBlockForCredential(request.provider, credentialId, report);
4412
+ }
4413
+
4414
+ #findStoredCredentialIdsForUsageReport(report: UsageReport): number[] {
4415
+ if (report.provider !== "openai-codex") return [];
4416
+ const email = this.#getUsageReportMetadataValue(report, "email")?.toLowerCase();
4417
+ const accountId = (
4418
+ this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report)
4419
+ )?.toLowerCase();
4420
+ if (!email && !accountId) return [];
4421
+ const matches: number[] = [];
4422
+ for (const entry of this.#getStoredCredentials(report.provider)) {
4423
+ const credential = entry.credential;
4424
+ if (credential.type !== "oauth") continue;
4425
+ const credentialEmail = credential.email?.trim().toLowerCase();
4426
+ const credentialAccountId = credential.accountId?.trim().toLowerCase();
4427
+ if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
4428
+ matches.push(entry.id);
4429
+ }
4430
+ }
4431
+ return matches;
4432
+ }
4433
+
4434
+ #reconcileCodexUsageBlocksFromReports(reports: UsageReport[]): void {
4435
+ const reconciled = new Set<number>();
4436
+ for (const report of reports) {
4437
+ if (!this.#isHealthyCodexUsageReport(report)) continue;
4438
+ for (const credentialId of this.#findStoredCredentialIdsForUsageReport(report)) {
4439
+ if (reconciled.has(credentialId)) continue;
4440
+ reconciled.add(credentialId);
4441
+ this.#reconcileCodexUsageBlockForCredential(report.provider, credentialId, report);
4442
+ }
4443
+ }
4444
+ }
4445
+
4297
4446
  #extractStructuredApiKeyToken(apiKey: string): string | undefined {
4298
4447
  if (!apiKey.startsWith("{")) return undefined;
4299
4448
  try {
@@ -4381,7 +4530,7 @@ export class AuthStorage {
4381
4530
  async rotateSessionCredential(
4382
4531
  provider: string,
4383
4532
  sessionId: string | undefined,
4384
- options?: { error?: unknown; modelId?: string; signal?: AbortSignal },
4533
+ options?: { error?: unknown; modelId?: string; apiKey?: string; signal?: AbortSignal },
4385
4534
  ): Promise<boolean> {
4386
4535
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
4387
4536
  if (!sessionCredential) return false;
@@ -4393,6 +4542,7 @@ export class AuthStorage {
4393
4542
  return (
4394
4543
  await this.markUsageLimitReached(provider, sessionId, {
4395
4544
  modelId: options?.modelId,
4545
+ apiKey: options?.apiKey,
4396
4546
  signal: options?.signal,
4397
4547
  })
4398
4548
  ).switched;
@@ -4654,6 +4804,7 @@ export class AuthStorage {
4654
4804
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
4655
4805
  if (!upsertCredentialBlock) return;
4656
4806
  upsertCredentialBlock(block);
4807
+ this.#invalidateUsageReportCacheForProviderKey(block.providerKey);
4657
4808
  this.#bumpGeneration("credential-block");
4658
4809
  }
4659
4810
 
@@ -4674,9 +4825,10 @@ export class AuthStorage {
4674
4825
  * 1. Runtime override (`--api-key`).
4675
4826
  * 2. Config override (`models.yml` `providers.<name>.apiKey`).
4676
4827
  * 3. Stored OAuth credential.
4677
- * 4. Env var overrides a stored static api_key (e.g. a stale broker copy).
4678
- * 5. Stored api_key credential.
4679
- * 6. Fallback resolver.
4828
+ * 4. API key persisted by a successful `/login`.
4829
+ * 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
4830
+ * 6. Stored api_key credential.
4831
+ * 7. Fallback resolver.
4680
4832
  *
4681
4833
  * The string is purely informational; consumers must not parse it.
4682
4834
  */
@@ -4691,14 +4843,16 @@ export class AuthStorage {
4691
4843
  const baseLabel = this.#sourceLabel ?? "local store";
4692
4844
  const stored = this.#getStoredCredentials(provider);
4693
4845
  const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
4694
- // Describe the stored credential of a given type, honoring the session sticky index.
4695
- const describeStored = (type: AuthCredential["type"]): string | undefined => {
4846
+ const describeStored = (
4847
+ type: AuthCredential["type"],
4848
+ filter?: (credential: AuthCredential) => boolean,
4849
+ ): string | undefined => {
4696
4850
  const typed = stored
4697
4851
  .map((entry, index) => ({ entry, index }))
4698
- .filter(({ entry }) => entry.credential.type === type);
4852
+ .filter(({ entry }) => entry.credential.type === type && (filter?.(entry.credential) ?? true));
4699
4853
  if (typed.length === 0) return undefined;
4700
- const index = session?.type === type ? session.index : typed[0].index;
4701
- const chosen = stored[index] ?? typed[0].entry;
4854
+ const sticky = session?.type === type ? typed.find(entry => entry.index === session.index) : undefined;
4855
+ const chosen = sticky?.entry ?? typed[0].entry;
4702
4856
  const credential = chosen.credential;
4703
4857
  const identity =
4704
4858
  credential.type === "oauth"
@@ -4707,11 +4861,19 @@ export class AuthStorage {
4707
4861
  return `${baseLabel} · ${type} #${chosen.id} (${identity})`;
4708
4862
  };
4709
4863
 
4710
- // A deliberate OAuth login wins; then an explicit env var; then a stored static api_key.
4864
+ // Deliberate login credentials win; then an explicit env var; then a stored static api_key.
4711
4865
  const oauthSource = describeStored("oauth");
4712
4866
  if (oauthSource) return oauthSource;
4867
+ const loginApiKeySource = describeStored(
4868
+ "api_key",
4869
+ credential => credential.type === "api_key" && credential.source === "login",
4870
+ );
4871
+ if (loginApiKeySource) return loginApiKeySource;
4713
4872
  if (getEnvApiKey(provider)) return `env (over ${baseLabel})`;
4714
- const apiKeySource = describeStored("api_key");
4873
+ const apiKeySource = describeStored(
4874
+ "api_key",
4875
+ credential => credential.type !== "api_key" || credential.source !== "login",
4876
+ );
4715
4877
  if (apiKeySource) return apiKeySource;
4716
4878
  if (this.#fallbackResolver?.(provider) !== undefined) return "fallback resolver";
4717
4879
  return undefined;
@@ -4776,9 +4938,10 @@ function normalizeStoredIdentityKey(identityKey: string | null | undefined): str
4776
4938
 
4777
4939
  function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
4778
4940
  if (credential.type === "api_key") {
4941
+ const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key };
4779
4942
  return {
4780
4943
  credentialType: "api_key",
4781
- data: JSON.stringify({ key: credential.key }),
4944
+ data: JSON.stringify(data),
4782
4945
  identityKey: null,
4783
4946
  };
4784
4947
  }
@@ -4806,7 +4969,8 @@ function deserializeCredential(row: AuthRow): AuthCredential | null {
4806
4969
  if (row.credential_type === "api_key") {
4807
4970
  const data = parsed as Record<string, unknown>;
4808
4971
  if (typeof data.key === "string") {
4809
- return { type: "api_key", key: data.key };
4972
+ const source = data.source === "login" ? "login" : undefined;
4973
+ return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key };
4810
4974
  }
4811
4975
  }
4812
4976
  if (row.credential_type === "oauth") {
@@ -127,12 +127,13 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon
127
127
 
128
128
  const midConversationSystemBeta = "mid-conversation-system-2026-04-07";
129
129
  const contextManagementBeta = "context-management-2025-06-27";
130
+ const structuredOutputsBeta = "structured-outputs-2025-12-15";
130
131
  const claudeCodeUtilityBetaDefaults = [
131
132
  "oauth-2025-04-20",
132
133
  "interleaved-thinking-2025-05-14",
133
134
  contextManagementBeta,
134
135
  "prompt-caching-scope-2026-01-05",
135
- "structured-outputs-2025-12-15",
136
+ structuredOutputsBeta,
136
137
  ] as const;
137
138
  const claudeCodeAgentBetaDefaults = [
138
139
  "claude-code-20250219",
@@ -159,10 +160,12 @@ function buildClaudeCodeBetas(
159
160
  agentRequest: boolean,
160
161
  thinkingRequest: boolean,
161
162
  redactThinking: boolean,
163
+ disableStrictTools = false,
162
164
  ): readonly string[] {
163
- if (!agentRequest && !redactThinking) return claudeCodeUtilityBetaDefaults;
165
+ if (!agentRequest && !redactThinking && !disableStrictTools) return claudeCodeUtilityBetaDefaults;
164
166
  const betas: string[] = [];
165
167
  for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) {
168
+ if (disableStrictTools && beta === structuredOutputsBeta) continue;
166
169
  betas.push(beta);
167
170
  // Match CC's header order: redact-thinking immediately follows interleaved-thinking.
168
171
  if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta);
@@ -1098,6 +1101,7 @@ export type AnthropicClientOptionsArgs = {
1098
1101
  hasTools?: boolean;
1099
1102
  thinkingEnabled?: boolean;
1100
1103
  thinkingDisplay?: AnthropicThinkingDisplay;
1104
+ disableStrictTools?: boolean;
1101
1105
  fetch?: FetchImpl;
1102
1106
  claudeCodeSessionId?: string;
1103
1107
  };
@@ -1458,6 +1462,16 @@ async function* observeDecodedAnthropicSdkEvents(
1458
1462
 
1459
1463
  const PROVIDER_MAX_RETRIES = 10;
1460
1464
 
1465
+ /**
1466
+ * How long `ping` keepalives may keep extending the idle deadline without any
1467
+ * semantic stream progress, as a multiple of the idle timeout. Anthropic pings
1468
+ * across legitimate generation gaps, so pings count as liveness — but a wedged
1469
+ * upstream that pings forever while producing no events must eventually trip
1470
+ * the idle watchdog instead of hanging an active tool-call stream without a
1471
+ * recovery path (#4900).
1472
+ */
1473
+ const PING_PROGRESS_MAX_IDLE_MULTIPLIER = 3;
1474
+
1461
1475
  /**
1462
1476
  * Log a malformed-stream-envelope anomaly without aborting the turn. The strict
1463
1477
  * parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead
@@ -1833,6 +1847,7 @@ const streamAnthropicOnce = (
1833
1847
  thinkingDisplay: options?.thinkingDisplay,
1834
1848
  fetch: options?.fetch,
1835
1849
  claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id),
1850
+ disableStrictTools,
1836
1851
  });
1837
1852
  client = created.client;
1838
1853
  isOAuthToken = created.isOAuthToken;
@@ -2002,11 +2017,20 @@ const streamAnthropicOnce = (
2002
2017
  }
2003
2018
  >();
2004
2019
 
2005
- // Pings keep the idle deadline alive once content is flowing, but a
2006
- // ping before message_start must not consume the first-event watchdog:
2007
- // it would flip the (retryable) pre-content stall classification into
2008
- // a terminal mid-stream idle timeout.
2020
+ // Pings keep the idle deadline alive once content is flowing (Anthropic
2021
+ // bridges legitimate generation gaps with keepalives), but only within a
2022
+ // bounded window: a wedged upstream that pings forever while the model
2023
+ // produces nothing must still trip the idle watchdog, otherwise an
2024
+ // active tool-call stream hangs unrecoverably with no retry (#4900).
2025
+ // A ping before message_start must not consume the first-event watchdog
2026
+ // either: it would flip the (retryable) pre-content stall classification
2027
+ // into a terminal mid-stream idle timeout.
2009
2028
  let sawNonPingEvent = false;
2029
+ let lastNonPingProgressAtMs = 0;
2030
+ const pingProgressCapMs =
2031
+ idleTimeoutMs !== undefined && idleTimeoutMs > 0
2032
+ ? idleTimeoutMs * PING_PROGRESS_MAX_IDLE_MULTIPLIER
2033
+ : undefined;
2010
2034
  const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, {
2011
2035
  idleTimeoutMs,
2012
2036
  firstItemTimeoutMs: firstEventTimeoutMs,
@@ -2016,8 +2040,13 @@ const streamAnthropicOnce = (
2016
2040
  onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
2017
2041
  abortSignal: options?.signal,
2018
2042
  isProgressItem: item => {
2019
- if ((item as AnthropicStreamEvent).type === "ping") return sawNonPingEvent;
2043
+ if ((item as AnthropicStreamEvent).type === "ping") {
2044
+ if (!sawNonPingEvent) return false;
2045
+ if (pingProgressCapMs === undefined) return true;
2046
+ return Date.now() - lastNonPingProgressAtMs < pingProgressCapMs;
2047
+ }
2020
2048
  sawNonPingEvent = true;
2049
+ lastNonPingProgressAtMs = Date.now();
2021
2050
  return true;
2022
2051
  },
2023
2052
  });
@@ -2648,8 +2677,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2648
2677
  thinkingDisplay,
2649
2678
  isOAuth,
2650
2679
  claudeCodeSessionId,
2680
+ disableStrictTools: disableStrictToolsOverride,
2651
2681
  } = args;
2652
2682
  const compat = model.compat;
2683
+ const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools;
2653
2684
  const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay;
2654
2685
  const needsFineGrainedToolStreamingBeta = hasTools && !compat.supportsEagerToolInputStreaming;
2655
2686
  const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
@@ -2722,7 +2753,12 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2722
2753
  isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
2723
2754
  claudeCodeSessionId,
2724
2755
  claudeCodeBetas: oauthToken
2725
- ? buildClaudeCodeBetas(hasTools || thinkingEnabled, thinkingEnabled, thinkingDisplay === "omitted")
2756
+ ? buildClaudeCodeBetas(
2757
+ hasTools || thinkingEnabled,
2758
+ thinkingEnabled,
2759
+ thinkingDisplay === "omitted",
2760
+ disableStrictTools,
2761
+ )
2726
2762
  : [],
2727
2763
  });
2728
2764
 
@@ -3529,6 +3565,40 @@ export function convertAnthropicMessages(
3529
3565
  });
3530
3566
  }
3531
3567
  }
3568
+ // Anthropic's replay validator rejects any non-`tool_use` block that
3569
+ // appears after a `tool_use` inside an assistant turn (400:
3570
+ // "tool_use ids were found without tool_result blocks immediately
3571
+ // after: <id>"). A persisted turn can violate this when a mid-turn
3572
+ // server-side fallback handoff lands after the primary model already
3573
+ // emitted a tool_use — the replayed content is then e.g.
3574
+ // [thinking, text, tool_use, fallback, text, tool_use] — and also for
3575
+ // the older cross-provider [text, tool_use, text] shape (issue #544).
3576
+ // Stable-partition into [...non-tool_use, ...tool_use], preserving each
3577
+ // side's relative order: the non-tool_use chain (thinking → text →
3578
+ // fallback → text) carries thinking signatures and the fallback
3579
+ // boundary marker whose order Anthropic verifies, while tool_use blocks
3580
+ // are unsigned and safe to defer to the tail. Fast-path untouched when
3581
+ // already in order so prompt-cache prefixes stay byte-identical.
3582
+ let sawToolUse = false;
3583
+ let needsPartition = false;
3584
+ for (const block of blocks) {
3585
+ if (block.type === "tool_use") {
3586
+ sawToolUse = true;
3587
+ } else if (sawToolUse) {
3588
+ needsPartition = true;
3589
+ break;
3590
+ }
3591
+ }
3592
+ if (needsPartition) {
3593
+ const nonToolUse: ContentBlockParam[] = [];
3594
+ const toolUse: ContentBlockParam[] = [];
3595
+ for (const block of blocks) {
3596
+ if (block.type === "tool_use") toolUse.push(block);
3597
+ else nonToolUse.push(block);
3598
+ }
3599
+ blocks.length = 0;
3600
+ blocks.push(...nonToolUse, ...toolUse);
3601
+ }
3532
3602
  if (blocks.length === 0) continue;
3533
3603
  params.push({
3534
3604
  role: "assistant",