@oh-my-pi/pi-ai 16.5.1 → 16.5.2
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 +16 -0
- package/dist/types/usage/openai-codex.d.ts +8 -1
- package/dist/types/utils/idle-iterator.d.ts +6 -6
- package/package.json +4 -4
- package/src/auth-storage.ts +63 -98
- package/src/providers/openai-codex-responses.ts +20 -9
- package/src/usage/openai-codex.ts +33 -0
- package/src/utils/idle-iterator.ts +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.5.2] - 2026-07-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added OpenAI Codex rate-limit response-header ingestion to proactively refresh account usage snapshots and rotate credentials before hitting 429 errors.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Optimized multi-account credential ranking to maximize quota utilization and prevent mid-session blocks by prioritizing expiring quota and demoting heavily used accounts.
|
|
14
|
+
- Improved responsiveness of credential blocking by bypassing the usage-ingestion throttle immediately when an account is detected as exhausted.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
- Fixed OpenAI Codex watchdog timeouts bypassing transport and session retries by ensuring each request attempt has an independent timeout signal.
|
|
20
|
+
|
|
5
21
|
## [16.5.1] - 2026-07-14
|
|
6
22
|
|
|
7
23
|
### 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
|
|
60
|
-
* the body stream is left to the iterator-level idle watchdog.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
4
|
+
"version": "16.5.2",
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.5.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.5.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.5.2",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.5.2",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.5.2",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
-
|
|
1111
|
+
secondaryRequiredDrain: number;
|
|
1106
1112
|
primaryUsed: number;
|
|
1107
|
-
|
|
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
|
-
|
|
1886
|
+
secondaryRequiredDrain: this.#computeWindowRequiredDrain(
|
|
1882
1887
|
secondaryTarget,
|
|
1883
1888
|
nowMs,
|
|
1884
1889
|
strategy.windowDefaults.secondaryMs,
|
|
1885
1890
|
),
|
|
1886
1891
|
primaryUsed: this.#normalizeUsageFraction(primary),
|
|
1887
|
-
|
|
1892
|
+
primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
|
|
1888
1893
|
orderPos,
|
|
1889
1894
|
});
|
|
1890
1895
|
}
|
|
1891
|
-
return this.#orderUsageRankedCandidates(ranked,
|
|
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
|
|
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
|
-
/**
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
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 (
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
4087
|
+
secondaryRequiredDrain: this.#computeWindowRequiredDrain(
|
|
4122
4088
|
secondaryTarget,
|
|
4123
4089
|
nowMs,
|
|
4124
4090
|
strategy.windowDefaults.secondaryMs,
|
|
4125
4091
|
),
|
|
4126
4092
|
primaryUsed: this.#normalizeUsageFraction(primary),
|
|
4127
|
-
|
|
4093
|
+
primaryRequiredDrain: this.#computeWindowRequiredDrain(primary, nowMs, strategy.windowDefaults.primaryMs),
|
|
4128
4094
|
orderPos,
|
|
4129
4095
|
});
|
|
4130
4096
|
}
|
|
4131
|
-
return this.#orderUsageRankedCandidates(ranked, args.
|
|
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,
|
|
@@ -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.
|
|
3752
|
-
//
|
|
3753
|
-
//
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
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
|
|
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:
|
|
3778
|
+
fetch: fetchAttempt,
|
|
3768
3779
|
timeout: false,
|
|
3769
3780
|
});
|
|
3770
3781
|
} finally {
|
|
3771
|
-
|
|
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
|
|
102
|
-
* the body stream is left to the iterator-level idle watchdog.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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.
|