@oh-my-pi/pi-ai 16.5.1 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.0] - 2026-07-15
6
+
7
+ ### Changed
8
+
9
+ - Improved Ollama streaming performance by parsing NDJSON response bytes directly instead of decoding and buffering network chunks as text.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Cursor TLS connection resets causing process-fatal uncaught exceptions, allowing the active turn to fail or retry gracefully without terminating the session.
14
+ - Fixed Amazon Bedrock stream error handling to correctly handle non-Error values that cannot be serialized by JSON.stringify.
15
+
16
+ ## [16.5.2] - 2026-07-14
17
+
18
+ ### Added
19
+
20
+ - Added OpenAI Codex rate-limit response-header ingestion to proactively refresh account usage snapshots and rotate credentials before hitting 429 errors.
21
+
22
+ ### Changed
23
+
24
+ - Optimized multi-account credential ranking to maximize quota utilization and prevent mid-session blocks by prioritizing expiring quota and demoting heavily used accounts.
25
+ - Improved responsiveness of credential blocking by bypassing the usage-ingestion throttle immediately when an account is detected as exhausted.
26
+
27
+ ### Fixed
28
+
29
+ - Fixed empty provider responses (such as from Cloud Code Assist API) being treated as non-retryable, allowing session retries and model-fallback chains to engage.
30
+ - Fixed OpenAI Codex watchdog timeouts bypassing transport and session retries by ensuring each request attempt has an independent timeout signal.
31
+
5
32
  ## [16.5.1] - 2026-07-14
6
33
 
7
34
  ### Added
@@ -1,3 +1,10 @@
1
- import type { CredentialRankingStrategy, UsageProvider } from "../usage.js";
1
+ import type { CredentialRankingStrategy, UsageProvider, UsageReport } from "../usage.js";
2
+ /**
3
+ * Parse Codex `x-codex-{primary,secondary}-*` rate-limit response headers into
4
+ * a usage report. The backend attaches these snapshots to every response, so
5
+ * ingesting them lets credential selection block an exhausted account before
6
+ * the next request burns a wire 429.
7
+ */
8
+ export declare function parseCodexRateLimitHeaders(headers: Record<string, string>, now?: number): UsageReport | null;
2
9
  export declare const openaiCodexUsageProvider: UsageProvider;
3
10
  export declare const codexRankingStrategy: CredentialRankingStrategy;
@@ -56,12 +56,12 @@ export declare function getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs?: numbe
56
56
  * pre-response request (issue #2422 regression: large `write` tool-call streams
57
57
  * died at the budget with `TimeoutError: The operation timed out.` despite
58
58
  * deltas actively flowing). This arms a `clearTimeout`-able timer instead;
59
- * callers MUST `clear()` as soon as `fetchWithRetry` resolves (headers in) so
60
- * the body stream is left to the iterator-level idle watchdog. The timer aborts
61
- * with a `TimeoutError` matching `AbortSignal.timeout`, so a genuine pre-response
62
- * stall behaves exactly as the prior code did `fetchWithRetry` normalizes the
63
- * abort to "Request was aborted" either way (only a post-headers abort ever
64
- * surfaced the raw `"The operation timed out."`, which clearing now prevents).
59
+ * callers MUST `clear()` as soon as the guarded transport attempt settles so
60
+ * the body stream is left to the iterator-level idle watchdog.
61
+ *
62
+ * Retrying callers MUST arm a fresh guard for each transport attempt and keep
63
+ * the retry loop's base signal reserved for caller cancellation. Reusing the
64
+ * guard as the loop signal makes its timeout indistinguishable from cancellation.
65
65
  *
66
66
  * Returns the caller signal unchanged (and a no-op `clear`) when no positive
67
67
  * timeout is configured.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.5.1",
4
+ "version": "17.0.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "16.5.1",
42
- "@oh-my-pi/pi-utils": "16.5.1",
43
- "@oh-my-pi/pi-wire": "16.5.1",
41
+ "@oh-my-pi/pi-catalog": "17.0.0",
42
+ "@oh-my-pi/pi-utils": "17.0.0",
43
+ "@oh-my-pi/pi-wire": "17.0.0",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -60,6 +60,12 @@ import { opencodeGoUsageProvider } from "./usage/opencode-go";
60
60
  import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
61
61
 
62
62
  const USAGE_RANKING_METRIC_EPSILON = 1e-9;
63
+ /**
64
+ * Primary (short, e.g. 5h) window used-fraction at or above which a candidate
65
+ * is demoted behind cooler siblings during ranking: a nearly exhausted short
66
+ * window means an imminent mid-session block, so drain urgency defers to it.
67
+ */
68
+ const PRIMARY_WINDOW_HOT_FRACTION = 0.85;
63
69
  const OAUTH_BEARER_FINGERPRINT_HISTORY_LIMIT = 8;
64
70
 
65
71
  /** SHA-256 bearer fingerprint, so superseded OAuth token bytes never enter the identity cache. */
@@ -1102,9 +1108,9 @@ type UsageRankedCandidate<T extends AuthCredential> = UsageCandidate<T> & {
1102
1108
  hasPriorityBoost: boolean;
1103
1109
  planPriority: number;
1104
1110
  secondaryUsed: number;
1105
- secondaryDrainRate: number;
1111
+ secondaryRequiredDrain: number;
1106
1112
  primaryUsed: number;
1107
- primaryDrainRate: number;
1113
+ primaryRequiredDrain: number;
1108
1114
  orderPos: number;
1109
1115
  };
1110
1116
  type RankedOAuthCandidate = UsageRankedCandidate<OAuthCredential>;
@@ -1798,7 +1804,6 @@ export class AuthStorage {
1798
1804
  order: number[];
1799
1805
  credentials: ApiKeySelection[];
1800
1806
  options?: AuthApiKeyOptions;
1801
- sessionId?: string;
1802
1807
  strategy: CredentialRankingStrategy;
1803
1808
  rankingContext: CredentialRankingContext;
1804
1809
  blockScope?: string;
@@ -1878,17 +1883,17 @@ export class AuthStorage {
1878
1883
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
1879
1884
  planPriority: 0,
1880
1885
  secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
1881
- secondaryDrainRate: this.#computeWindowDrainRate(
1886
+ secondaryRequiredDrain: this.#computeWindowRequiredDrain(
1882
1887
  secondaryTarget,
1883
1888
  nowMs,
1884
1889
  strategy.windowDefaults.secondaryMs,
1885
1890
  ),
1886
1891
  primaryUsed: this.#normalizeUsageFraction(primary),
1887
- primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
1892
+ primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
1888
1893
  orderPos,
1889
1894
  });
1890
1895
  }
1891
- return this.#orderUsageRankedCandidates(ranked, args.sessionId, "none");
1896
+ return this.#orderUsageRankedCandidates(ranked, "none");
1892
1897
  }
1893
1898
 
1894
1899
  async #selectApiKeyCredential(
@@ -1929,7 +1934,6 @@ export class AuthStorage {
1929
1934
  order,
1930
1935
  credentials,
1931
1936
  options,
1932
- sessionId,
1933
1937
  strategy,
1934
1938
  rankingContext,
1935
1939
  blockScope,
@@ -3121,6 +3125,8 @@ export class AuthStorage {
3121
3125
  options?: { sessionId?: string; baseUrl?: string },
3122
3126
  ): boolean {
3123
3127
  if (this.#fetchUsageReportsOverride) return false;
3128
+ const parseHeaders = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders;
3129
+ if (!parseHeaders) return false;
3124
3130
 
3125
3131
  const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
3126
3132
  if (!credential) return false;
@@ -3129,11 +3135,14 @@ export class AuthStorage {
3129
3135
  this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
3130
3136
  );
3131
3137
  const now = Date.now();
3132
- const last = this.#usageHeaderIngestAt.get(cacheKey);
3133
- if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
3134
-
3135
- const parsedReport = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
3138
+ const parsedReport = parseHeaders(headers, now);
3136
3139
  if (!parsedReport) return false;
3140
+ // Throttled to one ingest per interval — except when a window reads
3141
+ // exhausted: that snapshot must land immediately so the next getApiKey
3142
+ // blocks the credential instead of burning a wire 429 on the wall.
3143
+ const exhausted = parsedReport.limits.some(limit => this.#isUsageLimitExhausted(limit));
3144
+ const last = this.#usageHeaderIngestAt.get(cacheKey);
3145
+ if (!exhausted && last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
3137
3146
  const metadata: Record<string, unknown> = { ...(parsedReport.metadata ?? {}) };
3138
3147
  if (credential.accountId && metadata.accountId === undefined) metadata.accountId = credential.accountId;
3139
3148
  if (credential.email && metadata.email === undefined) metadata.email = credential.email;
@@ -3881,28 +3890,28 @@ export class AuthStorage {
3881
3890
  return Math.min(Math.max(usedFraction, 0), 1);
3882
3891
  }
3883
3892
 
3884
- /** Computes `usedFraction / elapsedHours` — consumption rate per hour within the current window. Lower drain rate = less pressure = preferred. */
3885
- #computeWindowDrainRate(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
3886
- const usedFraction = this.#normalizeUsageFraction(limit);
3887
- const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
3888
- if (!Number.isFinite(durationMs) || durationMs <= 0) {
3889
- return usedFraction;
3890
- }
3893
+ /**
3894
+ * Computes the required drain rate: `headroomFraction / remainingHours`
3895
+ * how fast the window's remaining quota must be consumed to fully use it
3896
+ * before it resets and expires. Higher = more headroom at risk of expiring
3897
+ * unused = ranked first, so selection chases quota that is about to be
3898
+ * wasted ("use it or lose it"). Without a reset clock the headroom
3899
+ * fraction alone is returned, degrading to most-headroom-first.
3900
+ */
3901
+ #computeWindowRequiredDrain(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
3902
+ const headroom = 1 - this.#normalizeUsageFraction(limit);
3903
+ if (headroom <= 0) return 0;
3891
3904
  const resetAt = this.#resolveWindowResetAt(limit?.window);
3892
- if (!Number.isFinite(resetAt)) {
3893
- return usedFraction;
3894
- }
3895
- const remainingWindowMs = (resetAt as number) - nowMs;
3896
- const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
3897
- const elapsedMs = durationMs - clampedRemainingWindowMs;
3898
- if (elapsedMs <= 0) {
3899
- return usedFraction;
3900
- }
3901
- const elapsedHours = elapsedMs / (60 * 60 * 1000);
3902
- if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) {
3903
- return usedFraction;
3905
+ if (resetAt === undefined) return headroom;
3906
+ const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
3907
+ let remainingMs = resetAt - nowMs;
3908
+ if (Number.isFinite(durationMs) && durationMs > 0) {
3909
+ remainingMs = Math.min(remainingMs, durationMs);
3904
3910
  }
3905
- return usedFraction / elapsedHours;
3911
+ // Floor at one minute: a stale report whose reset already passed must
3912
+ // not produce an unbounded urgency score.
3913
+ const remainingHours = Math.max(remainingMs, 60_000) / (60 * 60 * 1000);
3914
+ return headroom / remainingHours;
3906
3915
  }
3907
3916
 
3908
3917
  #compareUsageRankedCandidatePriority(
@@ -3921,11 +3930,28 @@ export class AuthStorage {
3921
3930
  return left.planPriority - right.planPriority;
3922
3931
  }
3923
3932
  if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
3924
- let metric = compareUsageRankingMetric(left.secondaryDrainRate, right.secondaryDrainRate);
3933
+ // Short-window guard: candidates whose primary (e.g. 5h) window is
3934
+ // nearly exhausted rank behind cool ones regardless of drain urgency —
3935
+ // overflow lands on the next-most-urgent cool account instead.
3936
+ const leftHot = left.primaryUsed >= PRIMARY_WINDOW_HOT_FRACTION;
3937
+ const rightHot = right.primaryUsed >= PRIMARY_WINDOW_HOT_FRACTION;
3938
+ if (leftHot !== rightHot) return leftHot ? 1 : -1;
3939
+ // Usage-backed candidates outrank unmeasured ones: required-drain
3940
+ // scores are only comparable between measured windows, and the
3941
+ // clockless headroom fallback (0..1) must not let an account whose
3942
+ // usage fetch failed shadow a measured sibling.
3943
+ const leftMeasured = left.usage !== null;
3944
+ const rightMeasured = right.usage !== null;
3945
+ if (leftMeasured !== rightMeasured) return leftMeasured ? -1 : 1;
3946
+ // Required drain, descending: the account whose remaining quota must
3947
+ // burn fastest to avoid expiring unused at its reset comes first, so
3948
+ // staggered resets land at ~100% utilization instead of stranding
3949
+ // headroom that a cooler sibling could have absorbed.
3950
+ let metric = compareUsageRankingMetric(right.secondaryRequiredDrain, left.secondaryRequiredDrain);
3925
3951
  if (metric !== 0) return metric;
3926
3952
  metric = compareUsageRankingMetric(left.secondaryUsed, right.secondaryUsed);
3927
3953
  if (metric !== 0) return metric;
3928
- metric = compareUsageRankingMetric(left.primaryDrainRate, right.primaryDrainRate);
3954
+ metric = compareUsageRankingMetric(right.primaryRequiredDrain, left.primaryRequiredDrain);
3929
3955
  if (metric !== 0) return metric;
3930
3956
  metric = compareUsageRankingMetric(left.primaryUsed, right.primaryUsed);
3931
3957
  if (metric !== 0) return metric;
@@ -3943,69 +3969,10 @@ export class AuthStorage {
3943
3969
 
3944
3970
  #orderUsageRankedCandidates<T extends AuthCredential>(
3945
3971
  candidates: UsageRankedCandidate<T>[],
3946
- sessionId: string | undefined,
3947
3972
  planRequirement: OpenAICodexPlanRequirement,
3948
3973
  ): UsageCandidate<T>[] {
3949
3974
  candidates.sort((left, right) => this.#compareUsageRankedCandidates(left, right, planRequirement));
3950
- if (!sessionId) {
3951
- return candidates.map(candidate => ({
3952
- selection: candidate.selection,
3953
- usage: candidate.usage,
3954
- usageChecked: candidate.usageChecked,
3955
- }));
3956
- }
3957
-
3958
- const unblocked = candidates.filter(candidate => !candidate.blocked);
3959
- if (unblocked.length <= 1) {
3960
- return candidates.map(candidate => ({
3961
- selection: candidate.selection,
3962
- usage: candidate.usage,
3963
- usageChecked: candidate.usageChecked,
3964
- }));
3965
- }
3966
-
3967
- const priorityByCandidate = new Map<UsageRankedCandidate<T>, number>();
3968
- let bucketIndex = 0;
3969
- let previous = unblocked[0];
3970
- const bucketByCandidate = new Map<UsageRankedCandidate<T>, number>();
3971
- for (const candidate of unblocked) {
3972
- if (
3973
- candidate !== previous &&
3974
- this.#compareUsageRankedCandidatePriority(previous, candidate, planRequirement) !== 0
3975
- ) {
3976
- bucketIndex += 1;
3977
- }
3978
- bucketByCandidate.set(candidate, bucketIndex);
3979
- previous = candidate;
3980
- }
3981
- const maxBucket = bucketIndex;
3982
- for (const candidate of unblocked) {
3983
- const bucket = bucketByCandidate.get(candidate) ?? 0;
3984
- priorityByCandidate.set(candidate, maxBucket === 0 ? 0 : 1 - bucket / maxBucket);
3985
- }
3986
-
3987
- let totalWeight = 0;
3988
- for (const candidate of unblocked) {
3989
- totalWeight += 1 + (priorityByCandidate.get(candidate) ?? 0);
3990
- }
3991
-
3992
- const hit = ((Bun.hash.xxHash32(sessionId) >>> 0) / 2 ** 32) * totalWeight;
3993
- let cursor = 0;
3994
- let selected = unblocked[unblocked.length - 1];
3995
- for (const candidate of unblocked) {
3996
- cursor += 1 + (priorityByCandidate.get(candidate) ?? 0);
3997
- if (hit < cursor) {
3998
- selected = candidate;
3999
- break;
4000
- }
4001
- }
4002
-
4003
- const ordered = [
4004
- selected,
4005
- ...unblocked.filter(candidate => candidate !== selected),
4006
- ...candidates.filter(candidate => candidate.blocked),
4007
- ];
4008
- return ordered.map(candidate => ({
3975
+ return candidates.map(candidate => ({
4009
3976
  selection: candidate.selection,
4010
3977
  usage: candidate.usage,
4011
3978
  usageChecked: candidate.usageChecked,
@@ -4019,7 +3986,6 @@ export class AuthStorage {
4019
3986
  planRequirement: OpenAICodexPlanRequirement;
4020
3987
  credentials: OAuthSelection[];
4021
3988
  options?: AuthApiKeyOptions;
4022
- sessionId?: string;
4023
3989
  strategy: CredentialRankingStrategy;
4024
3990
  rankingContext: CredentialRankingContext;
4025
3991
  blockScope?: string;
@@ -4118,17 +4084,17 @@ export class AuthStorage {
4118
4084
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
4119
4085
  planPriority: getOpenAICodexPlanPriority(usage, args.planRequirement),
4120
4086
  secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
4121
- secondaryDrainRate: this.#computeWindowDrainRate(
4087
+ secondaryRequiredDrain: this.#computeWindowRequiredDrain(
4122
4088
  secondaryTarget,
4123
4089
  nowMs,
4124
4090
  strategy.windowDefaults.secondaryMs,
4125
4091
  ),
4126
4092
  primaryUsed: this.#normalizeUsageFraction(primary),
4127
- primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
4093
+ primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
4128
4094
  orderPos,
4129
4095
  });
4130
4096
  }
4131
- return this.#orderUsageRankedCandidates(ranked, args.sessionId, args.planRequirement);
4097
+ return this.#orderUsageRankedCandidates(ranked, args.planRequirement);
4132
4098
  }
4133
4099
 
4134
4100
  /**
@@ -4196,7 +4162,6 @@ export class AuthStorage {
4196
4162
  order: rankingOrder,
4197
4163
  credentials,
4198
4164
  options,
4199
- sessionId,
4200
4165
  strategy: strategy!,
4201
4166
  rankingContext,
4202
4167
  blockScope,
@@ -510,7 +510,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
510
510
  for (const block of output.content) {
511
511
  if (block.type === "toolCall") clearStreamingPartialJson(block);
512
512
  }
513
- const baseMessage = error instanceof Error ? error.message : JSON.stringify(error);
513
+ let baseMessage: string;
514
+ try {
515
+ baseMessage = error instanceof Error ? error.message : (JSON.stringify(error) ?? String(error));
516
+ } catch {
517
+ baseMessage = String(error);
518
+ }
514
519
  // Enrich error with thinking block diagnostics for signature-related failures
515
520
  let diagnostics = "";
516
521
  if (baseMessage.includes("signature") || baseMessage.includes("thinking")) {
@@ -351,6 +351,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
351
351
  let h2Request: http2.ClientHttp2Stream | null = null;
352
352
  let heartbeatTimer: NodeJS.Timeout | null = null;
353
353
  let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
354
+ const h2Completion = Promise.withResolvers<void>();
355
+ let resolveH2: (() => void) | undefined = h2Completion.resolve;
354
356
 
355
357
  try {
356
358
  const apiKey = options?.apiKey;
@@ -406,6 +408,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
406
408
  } else {
407
409
  h2Client = http2.connect(baseUrl);
408
410
  }
411
+ h2Client.on("error", h2Completion.reject);
409
412
 
410
413
  h2Request = h2Client.request(requestHeaders);
411
414
 
@@ -449,8 +452,6 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
449
452
  conversationStateCache.set(conversationId, checkpoint);
450
453
  };
451
454
 
452
- let resolveH2: (() => void) | undefined;
453
-
454
455
  h2Request.on("response", headers => {
455
456
  debugResponseLogPromise = debugSession?.openResponseLog(
456
457
  `HTTP/2 ${headers[":status"] ?? ""}`.trim(),
@@ -517,8 +518,6 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
517
518
  }
518
519
  });
519
520
 
520
- h2Request.write(frameConnectMessage(requestBytes));
521
-
522
521
  const sendHeartbeat = () => {
523
522
  if (!h2Request || h2Request.closed) {
524
523
  return;
@@ -530,57 +529,55 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
530
529
  h2Request.write(frameConnectMessage(heartbeatBytes));
531
530
  };
532
531
 
533
- heartbeatTimer = setInterval(sendHeartbeat, 5000);
534
-
535
- await new Promise<void>((resolve, reject) => {
536
- resolveH2 = resolve;
532
+ const closeDebugLog = async (): Promise<void> => {
533
+ const log = await debugResponseLogPromise;
534
+ await log?.close();
535
+ };
537
536
 
538
- const closeDebugLog = async (): Promise<void> => {
539
- const log = await debugResponseLogPromise;
540
- await log?.close();
541
- };
537
+ h2Request.on("trailers", trailers => {
538
+ const status = trailers["grpc-status"];
539
+ const msg = trailers["grpc-message"];
540
+ if (status && status !== "0") {
541
+ void closeDebugLog().finally(() => {
542
+ h2Completion.reject(
543
+ new AIError.ProviderResponseError(
544
+ `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
545
+ { kind: "envelope" },
546
+ ),
547
+ );
548
+ });
549
+ }
550
+ });
542
551
 
543
- h2Request!.on("trailers", trailers => {
544
- const status = trailers["grpc-status"];
545
- const msg = trailers["grpc-message"];
546
- if (status && status !== "0") {
547
- void closeDebugLog().finally(() => {
548
- reject(
549
- new AIError.ProviderResponseError(
550
- `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
551
- { kind: "envelope" },
552
- ),
553
- );
554
- });
555
- }
556
- });
552
+ h2Request.on("end", () => {
553
+ resolveH2 = undefined;
554
+ void closeDebugLog()
555
+ .then(() => {
556
+ if (endStreamError) {
557
+ h2Completion.reject(endStreamError);
558
+ return;
559
+ }
560
+ h2Completion.resolve();
561
+ })
562
+ .catch(h2Completion.reject);
563
+ });
557
564
 
558
- h2Request!.on("end", () => {
559
- resolveH2 = undefined;
560
- void closeDebugLog()
561
- .then(() => {
562
- if (endStreamError) {
563
- reject(endStreamError);
564
- return;
565
- }
566
- resolve();
567
- })
568
- .catch(reject);
569
- });
565
+ h2Request.on("error", error => {
566
+ void closeDebugLog().finally(() => h2Completion.reject(error));
567
+ });
570
568
 
571
- h2Request!.on("error", error => {
572
- void closeDebugLog().finally(() => reject(error));
569
+ if (options?.signal) {
570
+ options.signal.addEventListener("abort", () => {
571
+ h2Request?.close();
572
+ void closeDebugLog().finally(() => {
573
+ h2Completion.reject(new AIError.AbortError());
574
+ });
573
575
  });
576
+ }
574
577
 
575
- if (options?.signal) {
576
- options.signal.addEventListener("abort", () => {
577
- h2Request?.close();
578
- void closeDebugLog().finally(() => {
579
- reject(new AIError.AbortError());
580
- });
581
- });
582
- }
583
- });
578
+ h2Request.write(frameConnectMessage(requestBytes));
579
+ heartbeatTimer = setInterval(sendHeartbeat, 5000);
580
+ await h2Completion.promise;
584
581
 
585
582
  endCurrentTextBlock(output, stream, state);
586
583
  endCurrentThinkingBlock(output, stream, state);
@@ -1,4 +1,4 @@
1
- import { fetchWithRetry, parseStreamingJson } from "@oh-my-pi/pi-utils";
1
+ import { fetchWithRetry, parseStreamingJson, readJsonl } from "@oh-my-pi/pi-utils";
2
2
  import * as AIError from "../error";
3
3
  import { getEnvApiKey } from "../stream";
4
4
  import type {
@@ -347,36 +347,6 @@ async function captureHttpErrorResponse(response: Response): Promise<CapturedHtt
347
347
  };
348
348
  }
349
349
 
350
- async function* iterateNdjson(stream: ReadableStream<Uint8Array>): AsyncGenerator<OllamaChatChunk> {
351
- const reader = stream.getReader();
352
- const decoder = new TextDecoder();
353
- let buffer = "";
354
- while (true) {
355
- const { done, value } = await reader.read();
356
- if (done) {
357
- break;
358
- }
359
- buffer += decoder.decode(value, { stream: true });
360
- while (true) {
361
- const newlineIndex = buffer.indexOf("\n");
362
- if (newlineIndex < 0) {
363
- break;
364
- }
365
- const line = buffer.slice(0, newlineIndex).trim();
366
- buffer = buffer.slice(newlineIndex + 1);
367
- if (!line) {
368
- continue;
369
- }
370
- yield JSON.parse(line) as OllamaChatChunk;
371
- }
372
- }
373
- buffer += decoder.decode();
374
- const tail = buffer.trim();
375
- if (tail) {
376
- yield JSON.parse(tail) as OllamaChatChunk;
377
- }
378
- }
379
-
380
350
  function createEmptyOutput(model: Model<"ollama-chat">): AssistantMessage {
381
351
  return {
382
352
  role: "assistant",
@@ -622,7 +592,7 @@ const streamOllamaOnce = (
622
592
  });
623
593
  }
624
594
  stream.push({ type: "start", partial: output });
625
- for await (const chunk of iterateNdjson(response.body)) {
595
+ for await (const chunk of readJsonl<OllamaChatChunk>(response.body)) {
626
596
  if (chunk.message?.thinking) {
627
597
  suppressHealedThinking = true;
628
598
  endActiveTextBlock();
@@ -3748,27 +3748,38 @@ async function openCodexSseEventStream(
3748
3748
  sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER),
3749
3749
  });
3750
3750
  // `wrapCodexSseStream` arms the iterator-level idle watchdog only after this
3751
- // fetch resolves. A pre-response timer still bounds time-to-first-byte (a
3752
- // proxy that accepts the POST but never sends headers would otherwise hang
3753
- // forever, since `timeout: false` disables Bun's native ceiling issue
3754
- // #2422). It MUST be cleared the instant headers arrive: an absolute
3755
- // `AbortSignal.timeout` would keep aborting the actively-streaming body.
3756
- const watchdog = armPreResponseTimeout(signal, firstEventTimeoutMs);
3751
+ // fetch resolves. Each transport attempt needs its own pre-response timer:
3752
+ // the retry loop's base signal remains reserved for caller cancellation, so
3753
+ // an internal timeout stays retryable while an explicit abort fails fast.
3754
+ let clearPreResponseTimeout: (() => void) | undefined;
3755
+ const fetchAttempt: FetchImpl = async (input, init) => {
3756
+ try {
3757
+ return await (fetchOverride ?? fetch)(input, init);
3758
+ } finally {
3759
+ clearPreResponseTimeout?.();
3760
+ clearPreResponseTimeout = undefined;
3761
+ }
3762
+ };
3757
3763
  let response: Response;
3758
3764
  try {
3759
3765
  response = await fetchWithRetry(url, {
3760
3766
  method: "POST",
3761
3767
  headers,
3762
3768
  body: JSON.stringify(body),
3763
- signal: watchdog.signal,
3769
+ signal,
3770
+ prepareInit: () => {
3771
+ const watchdog = armPreResponseTimeout(signal, firstEventTimeoutMs);
3772
+ clearPreResponseTimeout = watchdog.clear;
3773
+ return { signal: watchdog.signal };
3774
+ },
3764
3775
  maxAttempts: CODEX_MAX_RETRIES + 1,
3765
3776
  defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1),
3766
3777
  maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS,
3767
- fetch: fetchOverride,
3778
+ fetch: fetchAttempt,
3768
3779
  timeout: false,
3769
3780
  });
3770
3781
  } finally {
3771
- watchdog.clear();
3782
+ clearPreResponseTimeout?.();
3772
3783
  }
3773
3784
  CODEX_DEBUG &&
3774
3785
  logger.debug("[codex] codex response", {
@@ -344,11 +344,44 @@ function buildAdditionalUsageLimit(args: {
344
344
  };
345
345
  }
346
346
 
347
+ /**
348
+ * Parse Codex `x-codex-{primary,secondary}-*` rate-limit response headers into
349
+ * a usage report. The backend attaches these snapshots to every response, so
350
+ * ingesting them lets credential selection block an exhausted account before
351
+ * the next request burns a wire 429.
352
+ */
353
+ export function parseCodexRateLimitHeaders(headers: Record<string, string>, now = Date.now()): UsageReport | null {
354
+ const parseWindow = (key: "primary" | "secondary"): ParsedUsageWindow | undefined => {
355
+ const usedPercent = toNumber(headers[`x-codex-${key}-used-percent`]);
356
+ if (usedPercent === undefined) return undefined;
357
+ const windowMinutes = toNumber(headers[`x-codex-${key}-window-minutes`]);
358
+ const resetAt = toNumber(headers[`x-codex-${key}-reset-at`]);
359
+ return {
360
+ usedPercent,
361
+ limitWindowSeconds: windowMinutes === undefined ? undefined : windowMinutes * 60,
362
+ resetAt,
363
+ };
364
+ };
365
+ const primary = parseWindow("primary");
366
+ const secondary = parseWindow("secondary");
367
+ if (!primary && !secondary) return null;
368
+ const limits: UsageLimit[] = [];
369
+ if (primary) limits.push(buildUsageLimit({ key: "primary", window: primary, nowMs: now }));
370
+ if (secondary) limits.push(buildUsageLimit({ key: "secondary", window: secondary, nowMs: now }));
371
+ return {
372
+ provider: "openai-codex",
373
+ fetchedAt: now,
374
+ limits,
375
+ metadata: { source: "ratelimit-headers" },
376
+ };
377
+ }
378
+
347
379
  export const openaiCodexUsageProvider: UsageProvider = {
348
380
  id: "openai-codex",
349
381
  supports(params: UsageFetchParams): boolean {
350
382
  return params.provider === "openai-codex" && params.credential.type === "oauth";
351
383
  },
384
+ parseRateLimitHeaders: parseCodexRateLimitHeaders,
352
385
  async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
353
386
  if (params.provider !== "openai-codex") return null;
354
387
  const { credential } = params;
@@ -98,12 +98,12 @@ export function getOpenAIStreamFirstEventTimeoutMs(
98
98
  * pre-response request (issue #2422 regression: large `write` tool-call streams
99
99
  * died at the budget with `TimeoutError: The operation timed out.` despite
100
100
  * deltas actively flowing). This arms a `clearTimeout`-able timer instead;
101
- * callers MUST `clear()` as soon as `fetchWithRetry` resolves (headers in) so
102
- * the body stream is left to the iterator-level idle watchdog. The timer aborts
103
- * with a `TimeoutError` matching `AbortSignal.timeout`, so a genuine pre-response
104
- * stall behaves exactly as the prior code did `fetchWithRetry` normalizes the
105
- * abort to "Request was aborted" either way (only a post-headers abort ever
106
- * surfaced the raw `"The operation timed out."`, which clearing now prevents).
101
+ * callers MUST `clear()` as soon as the guarded transport attempt settles so
102
+ * the body stream is left to the iterator-level idle watchdog.
103
+ *
104
+ * Retrying callers MUST arm a fresh guard for each transport attempt and keep
105
+ * the retry loop's base signal reserved for caller cancellation. Reusing the
106
+ * guard as the loop signal makes its timeout indistinguishable from cancellation.
107
107
  *
108
108
  * Returns the caller signal unchanged (and a no-op `clear`) when no positive
109
109
  * timeout is configured.