@oh-my-pi/pi-ai 18.1.10 → 18.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.11] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - GitHub Copilot sign-in now requests only basic profile access, restoring login for Enterprise organizations that reject repository, gist, and Codespaces permissions ([#10656](https://github.com/can1357/oh-my-pi/issues/10656)).
10
+
5
11
  ## [18.1.9] - 2026-09-04
6
12
 
7
13
  ### Added
@@ -27,6 +33,7 @@
27
33
  ### Fixed
28
34
 
29
35
  - Fixed DeepSeek-family Responses replay (e.g. opencode-go) rejecting a resumed thinking-mode turn with `400 The reasoning_text in the thinking mode must be passed back to the API` when compaction dropped the turn's reasoning; a non-empty placeholder is now synthesized instead of an empty `reasoning_text` ([#10690](https://github.com/can1357/oh-my-pi/issues/10690)).
36
+ - Fixed pi-native streams treating a connection that closed before its terminal event as a successful empty response; incomplete streams and namespaced gateway 5xx failures now remain retryable.
30
37
 
31
38
  ## [18.1.6] - 2026-09-03
32
39
 
@@ -50,6 +57,7 @@
50
57
  - Anthropic and OpenRouter credit-exhaustion errors now automatically switch to a sibling account instead of stopping the turn with a retry hint.
51
58
  - Fixed OpenCode Go and Zen requests by including the required stable per-conversation session identification.
52
59
  - Improved Anthropic prompt caching so explicit cache breakpoints preserve reusable tools and system prompts when the message tail changes.
60
+ - Anthropic and OpenRouter 402 credit-exhaustion errors ("would exceed your available credits", "Insufficient credits") now switch to a sibling account instead of stopping the turn with a retry hint.
53
61
 
54
62
  ## [18.1.5] - 2026-09-03
55
63
 
@@ -496,10 +496,40 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify.js";
496
496
  * multi-hour) retry-after when it is sooner. `retryAtMs` is `undefined` when
497
497
  * no sibling credentials exist at all, or when the session has no tracked
498
498
  * credential to rotate away from.
499
+ *
500
+ * `blockedUntilMs` (epoch ms) is the just-blocked credential's own unblock
501
+ * deadline — the later of the caller's retry-after and any exhausted window
502
+ * the usage report reveals. Callers that wait the account out (instead of
503
+ * rotating) must sleep until this, not the error-text hint alone.
504
+ *
505
+ * `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
506
+ * stored for this credential before this call. The merged `blockedUntilMs`
507
+ * masks a pre-existing block shorter than this call's own heuristic
508
+ * fallback (`Math.max` in the mark), so callers that replace that heuristic
509
+ * with an authoritative report window must consult the prior deadline to
510
+ * keep honoring the earlier response's provider-stated block.
511
+ *
512
+ * `priorBlockedUntilTimed` is `true` when that prior deadline came from
513
+ * provider-stated timing (a parsed hint or usage-report reset) rather than
514
+ * another session's heuristic guess — only timed priors may extend a wait
515
+ * past an authoritative report window. Persisted blocks carry no provenance
516
+ * and count as untimed: a stale persisted heuristic must not outrank a
517
+ * fresh complete report (longer persisted deadlines still win through the
518
+ * merged `blockedUntilMs`).
519
+ *
520
+ * `reportResetAtMs` (epoch ms) is present only when the usage report is a
521
+ * complete authority for the wait: every exhausted window carries a future
522
+ * reset, so sleeping until the latest one can actually clear the account. A
523
+ * permanent cap alongside a timed window (or no report at all) leaves it
524
+ * unset, and the heuristic fallback alone must never authorize a wait.
499
525
  */
500
526
  export interface UsageLimitMarkResult {
501
527
  switched: boolean;
502
528
  retryAtMs?: number;
529
+ blockedUntilMs?: number;
530
+ priorBlockedUntilMs?: number;
531
+ priorBlockedUntilTimed?: boolean;
532
+ reportResetAtMs?: number;
503
533
  }
504
534
  export type ModelUsageHealthState = "healthy" | "reserve" | "depleted" | "unknown";
505
535
  export interface ModelUsageAccountHealth {
@@ -1013,6 +1043,12 @@ export declare class AuthStorage {
1013
1043
  */
1014
1044
  markUsageLimitReached(provider: string, sessionId: string | undefined, options?: {
1015
1045
  retryAfterMs?: number;
1046
+ /**
1047
+ * Whether `retryAfterMs` came from provider-stated timing (a parsed
1048
+ * retry hint) rather than a heuristic/default guess. A report reset
1049
+ * extending the block counts as provider timing regardless.
1050
+ */
1051
+ providerTimed?: boolean;
1016
1052
  baseUrl?: string;
1017
1053
  modelId?: string;
1018
1054
  apiKey?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.1.10",
3
+ "version": "18.1.11",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -124,11 +124,11 @@
124
124
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
125
125
  },
126
126
  "dependencies": {
127
- "@oh-my-pi/omptype": "18.1.10",
128
- "@oh-my-pi/pi-catalog": "18.1.10",
129
- "@oh-my-pi/pi-natives": "18.1.10",
130
- "@oh-my-pi/pi-utils": "18.1.10",
131
- "@oh-my-pi/pi-wire": "18.1.10"
127
+ "@oh-my-pi/omptype": "18.1.11",
128
+ "@oh-my-pi/pi-catalog": "18.1.11",
129
+ "@oh-my-pi/pi-natives": "18.1.11",
130
+ "@oh-my-pi/pi-utils": "18.1.11",
131
+ "@oh-my-pi/pi-wire": "18.1.11"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/bun": "^1.3.14"
@@ -251,6 +251,7 @@ async function refreshGatewayApiKeyAfterAuthError(
251
251
  const retryAfterMs = extractRetryHint(undefined, message);
252
252
  const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
253
253
  retryAfterMs,
254
+ providerTimed: retryAfterMs !== undefined,
254
255
  baseUrl: model.baseUrl,
255
256
  modelId: model.id,
256
257
  apiKey: oldKey,
@@ -755,10 +755,40 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
755
755
  * multi-hour) retry-after when it is sooner. `retryAtMs` is `undefined` when
756
756
  * no sibling credentials exist at all, or when the session has no tracked
757
757
  * credential to rotate away from.
758
+ *
759
+ * `blockedUntilMs` (epoch ms) is the just-blocked credential's own unblock
760
+ * deadline — the later of the caller's retry-after and any exhausted window
761
+ * the usage report reveals. Callers that wait the account out (instead of
762
+ * rotating) must sleep until this, not the error-text hint alone.
763
+ *
764
+ * `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
765
+ * stored for this credential before this call. The merged `blockedUntilMs`
766
+ * masks a pre-existing block shorter than this call's own heuristic
767
+ * fallback (`Math.max` in the mark), so callers that replace that heuristic
768
+ * with an authoritative report window must consult the prior deadline to
769
+ * keep honoring the earlier response's provider-stated block.
770
+ *
771
+ * `priorBlockedUntilTimed` is `true` when that prior deadline came from
772
+ * provider-stated timing (a parsed hint or usage-report reset) rather than
773
+ * another session's heuristic guess — only timed priors may extend a wait
774
+ * past an authoritative report window. Persisted blocks carry no provenance
775
+ * and count as untimed: a stale persisted heuristic must not outrank a
776
+ * fresh complete report (longer persisted deadlines still win through the
777
+ * merged `blockedUntilMs`).
778
+ *
779
+ * `reportResetAtMs` (epoch ms) is present only when the usage report is a
780
+ * complete authority for the wait: every exhausted window carries a future
781
+ * reset, so sleeping until the latest one can actually clear the account. A
782
+ * permanent cap alongside a timed window (or no report at all) leaves it
783
+ * unset, and the heuristic fallback alone must never authorize a wait.
758
784
  */
759
785
  export interface UsageLimitMarkResult {
760
786
  switched: boolean;
761
787
  retryAtMs?: number;
788
+ blockedUntilMs?: number;
789
+ priorBlockedUntilMs?: number;
790
+ priorBlockedUntilTimed?: boolean;
791
+ reportResetAtMs?: number;
762
792
  }
763
793
 
764
794
  export type ModelUsageHealthState = "healthy" | "reserve" | "depleted" | "unknown";
@@ -1325,6 +1355,14 @@ export class AuthStorage {
1325
1355
  #oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
1326
1356
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
1327
1357
  #credentialBackoff: Map<string, Map<number, number>> = new Map();
1358
+ /**
1359
+ * Provenance of each in-memory block: `true` when the deadline came from
1360
+ * provider-stated timing (a parsed retry hint or a usage-report reset),
1361
+ * `false` when it is a heuristic/default guess. Mirrors
1362
+ * {@link AuthStorage.#credentialBackoff} longest-wins semantics, tracking
1363
+ * the flag of whichever deadline currently wins.
1364
+ */
1365
+ #credentialBackoffProviderTimed: Map<string, Map<number, boolean>> = new Map();
1328
1366
  /** Earliest time a freshly-set in-memory block may be cleared by live usage reconciliation. */
1329
1367
  #credentialBackoffProbeAfter: Map<string, Map<number, number>> = new Map();
1330
1368
  /**
@@ -1800,6 +1838,10 @@ export class AuthStorage {
1800
1838
  if (backoffMap.size === 0) {
1801
1839
  this.#credentialBackoff.delete(backoffKey);
1802
1840
  }
1841
+ this.#credentialBackoffProviderTimed.get(backoffKey)?.delete(credentialIndex);
1842
+ if (this.#credentialBackoffProviderTimed.get(backoffKey)?.size === 0) {
1843
+ this.#credentialBackoffProviderTimed.delete(backoffKey);
1844
+ }
1803
1845
  const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey);
1804
1846
  probeAfterMap?.delete(credentialIndex);
1805
1847
  if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(backoffKey);
@@ -1906,13 +1948,48 @@ export class AuthStorage {
1906
1948
  return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope) !== undefined;
1907
1949
  }
1908
1950
 
1909
- /** Marks a credential as blocked until the specified time. */
1951
+ /**
1952
+ * Whether the in-memory block currently sitting at exactly `deadline` for
1953
+ * this credential was written with provider-stated timing. Mirrors the
1954
+ * scope enumeration of {@link AuthStorage.#getCredentialBlockedUntil}.
1955
+ * A deadline with no matching in-memory entry came from the persisted
1956
+ * store, which carries no provenance — a stale persisted heuristic guess
1957
+ * (pre-restart hintless response) must not outrank a fresh complete usage
1958
+ * report, so persisted-only deadlines count as untimed. Persisted
1959
+ * deadlines longer than this call's own request still win through the
1960
+ * merged `blockedUntilMs` comparison, which needs no provenance.
1961
+ */
1962
+ #isProviderTimedBlock(
1963
+ providerKey: string,
1964
+ blockScopeOrScopes: string | readonly string[] | undefined,
1965
+ credentialIndex: number,
1966
+ deadline: number,
1967
+ ): boolean {
1968
+ const scopes = (
1969
+ typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])
1970
+ ).filter(scope => scope.length > 0);
1971
+ for (const key of [providerKey, ...scopes.map(scope => this.#toScopedBackoffKey(providerKey, scope))]) {
1972
+ const entry = this.#credentialBackoff.get(key)?.get(credentialIndex);
1973
+ if (entry === undefined || entry !== deadline) continue;
1974
+ if (this.#credentialBackoffProviderTimed.get(key)?.get(credentialIndex) === true) return true;
1975
+ }
1976
+ return false;
1977
+ }
1978
+
1979
+ /**
1980
+ * Marks a credential as blocked until the specified time. `providerTimed`
1981
+ * records whether the requested deadline comes from provider-stated
1982
+ * timing (a parsed retry hint or a usage-report reset) rather than a
1983
+ * heuristic/default guess; the stored block keeps the provenance of
1984
+ * whichever deadline wins the longest-wins merge.
1985
+ */
1910
1986
  #markCredentialBlocked(
1911
1987
  provider: string,
1912
1988
  providerKey: string,
1913
1989
  credentialIndex: number,
1914
1990
  blockedUntilMs: number,
1915
1991
  blockScope: string | undefined = undefined,
1992
+ providerTimed = false,
1916
1993
  ): void {
1917
1994
  const backoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
1918
1995
  const backoffMap = this.#credentialBackoff.get(backoffKey) ?? new Map<number, number>();
@@ -1920,6 +1997,16 @@ export class AuthStorage {
1920
1997
  const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1921
1998
  backoffMap.set(credentialIndex, nextBlockedUntil);
1922
1999
  this.#credentialBackoff.set(backoffKey, backoffMap);
2000
+ const timedMap = this.#credentialBackoffProviderTimed.get(backoffKey) ?? new Map<number, boolean>();
2001
+ const existingTimed = timedMap.get(credentialIndex) ?? false;
2002
+ const nextTimed =
2003
+ blockedUntilMs > existing
2004
+ ? providerTimed
2005
+ : blockedUntilMs === existing
2006
+ ? existingTimed || providerTimed
2007
+ : existingTimed;
2008
+ timedMap.set(credentialIndex, nextTimed);
2009
+ this.#credentialBackoffProviderTimed.set(backoffKey, timedMap);
1923
2010
  const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey) ?? new Map<number, number>();
1924
2011
  probeAfterMap.set(credentialIndex, Math.min(nextBlockedUntil, Date.now() + USAGE_REPORT_TTL_MS));
1925
2012
  this.#credentialBackoffProbeAfter.set(backoffKey, probeAfterMap);
@@ -2205,6 +2292,7 @@ export class AuthStorage {
2205
2292
  selection.index,
2206
2293
  blockedUntil,
2207
2294
  args.blockScope,
2295
+ resetAtMs !== undefined,
2208
2296
  );
2209
2297
  blocked = true;
2210
2298
  }
@@ -2310,6 +2398,11 @@ export class AuthStorage {
2310
2398
  this.#credentialBackoff.delete(key);
2311
2399
  }
2312
2400
  }
2401
+ for (const key of this.#credentialBackoffProviderTimed.keys()) {
2402
+ if (key.startsWith(`${provider}:`)) {
2403
+ this.#credentialBackoffProviderTimed.delete(key);
2404
+ }
2405
+ }
2313
2406
  }
2314
2407
 
2315
2408
  /** Updates credential at index in-place (used for OAuth token refresh) */
@@ -4600,11 +4693,43 @@ export class AuthStorage {
4600
4693
  targetIndex: number,
4601
4694
  blockedUntil: number,
4602
4695
  routing: CredentialBlockRouting,
4696
+ providerTimed: boolean,
4603
4697
  ): UsageLimitMarkResult {
4698
+ // Snapshot the live block deadline BEFORE this call's mark: the merged
4699
+ // value below masks a pre-existing block shorter than this call's own
4700
+ // request (longest-wins), and recovery that replaces this call's
4701
+ // heuristic fallback with an authoritative report window still needs
4702
+ // the prior response's provider-stated deadline.
4703
+ const priorBlockedUntilMs =
4704
+ targetIndex >= 0
4705
+ ? this.#getCredentialBlockedUntil(provider, routing.providerKey, targetIndex, routing.blockScope)
4706
+ : undefined;
4707
+ const priorBlockedUntilTimed =
4708
+ targetIndex >= 0 && priorBlockedUntilMs !== undefined
4709
+ ? this.#isProviderTimedBlock(routing.providerKey, routing.blockScope, targetIndex, priorBlockedUntilMs)
4710
+ : false;
4604
4711
  if (targetIndex >= 0) {
4605
- this.#markCredentialBlocked(provider, routing.providerKey, targetIndex, blockedUntil, routing.blockScope);
4712
+ this.#markCredentialBlocked(
4713
+ provider,
4714
+ routing.providerKey,
4715
+ targetIndex,
4716
+ blockedUntil,
4717
+ routing.blockScope,
4718
+ providerTimed,
4719
+ );
4606
4720
  }
4607
4721
 
4722
+ // Report the merged deadline the block map actually stores, not this
4723
+ // call's input: a sibling session may have established a longer block
4724
+ // for the same credential (out-of-order usage-limit responses), and
4725
+ // #markCredentialBlocked keeps the longest. Waiting on the shorter
4726
+ // value would retry before the credential is actually usable.
4727
+ const mergedBlockedUntil =
4728
+ targetIndex >= 0
4729
+ ? (this.#getCredentialBlockedUntil(provider, routing.providerKey, targetIndex, routing.blockScope) ??
4730
+ blockedUntil)
4731
+ : blockedUntil;
4732
+
4608
4733
  const remainingCredentials = this.#getCredentialsForProvider(provider)
4609
4734
  .map((credential, index) => ({ credential, index }))
4610
4735
  .filter(
@@ -4622,10 +4747,22 @@ export class AuthStorage {
4622
4747
  candidate.index,
4623
4748
  routing.siblingBlockScopes,
4624
4749
  );
4625
- if (candidateBlockedUntil === undefined) return { switched: true };
4750
+ if (candidateBlockedUntil === undefined)
4751
+ return {
4752
+ switched: true,
4753
+ blockedUntilMs: mergedBlockedUntil,
4754
+ priorBlockedUntilMs,
4755
+ priorBlockedUntilTimed,
4756
+ };
4626
4757
  if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
4627
4758
  }
4628
- return { switched: false, retryAtMs };
4759
+ return {
4760
+ switched: false,
4761
+ retryAtMs,
4762
+ blockedUntilMs: mergedBlockedUntil,
4763
+ priorBlockedUntilMs,
4764
+ priorBlockedUntilTimed,
4765
+ };
4629
4766
  }
4630
4767
 
4631
4768
  /**
@@ -4640,6 +4777,12 @@ export class AuthStorage {
4640
4777
  sessionId: string | undefined,
4641
4778
  options?: {
4642
4779
  retryAfterMs?: number;
4780
+ /**
4781
+ * Whether `retryAfterMs` came from provider-stated timing (a parsed
4782
+ * retry hint) rather than a heuristic/default guess. A report reset
4783
+ * extending the block counts as provider timing regardless.
4784
+ */
4785
+ providerTimed?: boolean;
4643
4786
  baseUrl?: string;
4644
4787
  modelId?: string;
4645
4788
  apiKey?: string;
@@ -4673,7 +4816,17 @@ export class AuthStorage {
4673
4816
  const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
4674
4817
  const now = Date.now();
4675
4818
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4676
-
4819
+ // Heuristic/default fallbacks are guesses; provider-stated hints and
4820
+ // report-derived extensions are timed.
4821
+ let providerTimed = options?.providerTimed === true;
4822
+
4823
+ // Epoch ms of the usage-report-derived reset. Authoritative only when
4824
+ // EVERY exhausted window carries a future reset: a permanent cap
4825
+ // alongside a timed window must not authorize a wait that can never
4826
+ // clear it, and a bare heuristic must not sleep alone. Set
4827
+ // independently of whether the report extends the hint so a shorter
4828
+ // authoritative window still counts as provider timing.
4829
+ let reportResetAtMs: number | undefined;
4677
4830
  if (target && routing.strategy) {
4678
4831
  const report = await raceUsageWithSignal(
4679
4832
  this.#getUsageReport(provider, target.credential, options),
@@ -4685,6 +4838,15 @@ export class AuthStorage {
4685
4838
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
4686
4839
  if (resetAtMs && resetAtMs > blockedUntil) {
4687
4840
  blockedUntil = resetAtMs;
4841
+ providerTimed = true;
4842
+ }
4843
+ const nowMs = Date.now();
4844
+ const exhaustedLimits = scopedLimits.filter(limit => this.#isUsageLimitExhausted(limit));
4845
+ const futureResets = exhaustedLimits
4846
+ .map(limit => this.#resolveWindowResetAt(limit.window))
4847
+ .filter((reset): reset is number => reset !== undefined && reset > nowMs);
4848
+ if (exhaustedLimits.length > 0 && futureResets.length === exhaustedLimits.length) {
4849
+ reportResetAtMs = Math.max(...futureResets);
4688
4850
  }
4689
4851
  }
4690
4852
  }
@@ -4696,7 +4858,15 @@ export class AuthStorage {
4696
4858
  const targetIndex = this.#getStoredCredentials(provider).findIndex(
4697
4859
  entry => entry.id === targetCredentialId && entry.credential.type === credentialType,
4698
4860
  );
4699
- return this.#blockCredentialForRotation(provider, credentialType, targetIndex, blockedUntil, routing);
4861
+ const rotation = this.#blockCredentialForRotation(
4862
+ provider,
4863
+ credentialType,
4864
+ targetIndex,
4865
+ blockedUntil,
4866
+ routing,
4867
+ providerTimed,
4868
+ );
4869
+ return reportResetAtMs === undefined ? rotation : { ...rotation, reportResetAtMs };
4700
4870
  }
4701
4871
 
4702
4872
  #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
@@ -4906,6 +5076,7 @@ export class AuthStorage {
4906
5076
  selection.index,
4907
5077
  blockedUntil,
4908
5078
  args.blockScope,
5079
+ resetAtMs !== undefined,
4909
5080
  );
4910
5081
  blocked = true;
4911
5082
  }
@@ -5562,6 +5733,7 @@ export class AuthStorage {
5562
5733
  selection.index,
5563
5734
  resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
5564
5735
  blockScope,
5736
+ resetAtMs !== undefined,
5565
5737
  );
5566
5738
  return undefined;
5567
5739
  }
@@ -5642,6 +5814,7 @@ export class AuthStorage {
5642
5814
  selection.index,
5643
5815
  resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
5644
5816
  blockScope,
5817
+ resetAtMs !== undefined,
5645
5818
  );
5646
5819
  return undefined;
5647
5820
  }
@@ -6335,6 +6508,11 @@ export class AuthStorage {
6335
6508
  backoffMap.delete(index);
6336
6509
  if (backoffMap.size === 0) this.#credentialBackoff.delete(key);
6337
6510
  }
6511
+ for (const [key, timedMap] of this.#credentialBackoffProviderTimed) {
6512
+ if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
6513
+ timedMap.delete(index);
6514
+ if (timedMap.size === 0) this.#credentialBackoffProviderTimed.delete(key);
6515
+ }
6338
6516
  for (const [key, probeAfterMap] of this.#credentialBackoffProbeAfter) {
6339
6517
  if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
6340
6518
  probeAfterMap.delete(index);
@@ -6396,6 +6574,9 @@ export class AuthStorage {
6396
6574
  const backoffMap = this.#credentialBackoff.get(key);
6397
6575
  backoffMap?.delete(credentialIndex);
6398
6576
  if (backoffMap?.size === 0) this.#credentialBackoff.delete(key);
6577
+ const timedMap = this.#credentialBackoffProviderTimed.get(key);
6578
+ timedMap?.delete(credentialIndex);
6579
+ if (timedMap?.size === 0) this.#credentialBackoffProviderTimed.delete(key);
6399
6580
  const probeAfterMap = this.#credentialBackoffProbeAfter.get(key);
6400
6581
  probeAfterMap?.delete(credentialIndex);
6401
6582
  if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(key);
@@ -6687,6 +6868,7 @@ export class AuthStorage {
6687
6868
  return (
6688
6869
  await this.markUsageLimitReached(provider, sessionId, {
6689
6870
  retryAfterMs,
6871
+ providerTimed: retryAfterMs !== undefined,
6690
6872
  modelId: options?.modelId,
6691
6873
  apiKey: options?.apiKey,
6692
6874
  credentialId: options?.credentialId,
@@ -6726,6 +6908,7 @@ export class AuthStorage {
6726
6908
  sessionCredential.index,
6727
6909
  Date.now() + AuthStorage.#defaultBackoffMs,
6728
6910
  routing,
6911
+ false,
6729
6912
  ).switched;
6730
6913
  }
6731
6914
 
@@ -157,7 +157,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
157
157
  const TRANSIENT_ENVELOPE_TRUNCATION_PATTERN = /before message_(?:start|stop)/i;
158
158
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
159
159
  export const TRANSIENT_TRANSPORT_PATTERN =
160
- /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
160
+ /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
161
161
  const AUTH_FAILURE_PATTERN =
162
162
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
163
163
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -236,10 +236,6 @@ export function streamPiNative<TApi extends Api>(
236
236
  }
237
237
 
238
238
  if (!sawTerminal) {
239
- // SSE closed before a terminal event reached us — synthesize one
240
- // so awaiters of `.result()` resolve instead of hanging forever.
241
- // Matches the gateway's own defensive fallback in
242
- // `pi-native-server.encodeStream`.
243
239
  const aborted = abortTracker.wasCallerAbort();
244
240
  const partial = makeSyntheticAssistant(model as Model<Api>);
245
241
  if (aborted) {
@@ -247,8 +243,16 @@ export function streamPiNative<TApi extends Api>(
247
243
  partial.errorMessage = "stream closed without terminal event";
248
244
  stream.push({ type: "error", reason: "aborted", error: partial });
249
245
  } else {
250
- partial.stopReason = "stop";
251
- stream.push({ type: "done", reason: "stop", message: partial });
246
+ stream.fail(
247
+ new AIError.ProviderResponseError(
248
+ "pi-native stream read error: stream closed before a terminal response event",
249
+ {
250
+ provider: model.provider,
251
+ kind: "incomplete-stream",
252
+ },
253
+ ),
254
+ );
255
+ return;
252
256
  }
253
257
  }
254
258
  stream.end();
@@ -16,7 +16,7 @@ import type { FetchImpl } from "../../types";
16
16
  import type { OAuthController, OAuthCredentials } from "./types";
17
17
 
18
18
  const CLIENT_ID = "Ov23ctDVkRmgkPke0Mmm";
19
- const OAUTH_SCOPE = "read:user,read:org,repo,gist,codespace";
19
+ const OAUTH_SCOPE = "read:user";
20
20
  const OAUTH_HEADERS = {
21
21
  Accept: "application/json",
22
22
  "Content-Type": "application/x-www-form-urlencoded",