@oh-my-pi/pi-ai 18.2.0 → 18.2.1
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 +35 -0
- package/dist/types/auth-broker/remote-store.d.ts +17 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/session-state.d.ts +65 -0
- package/dist/types/auth-storage.d.ts +16 -0
- package/dist/types/error/body-error.d.ts +15 -0
- package/dist/types/error/flags.d.ts +16 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +6 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
- package/dist/types/providers/openai-shared.d.ts +20 -3
- package/dist/types/registry/oauth/perplexity.d.ts +1 -7
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/types.d.ts +3 -1
- package/dist/types/usage.d.ts +8 -0
- package/dist/types/utils/block-symbols.d.ts +36 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/retry-after.d.ts +2 -0
- package/dist/types/utils/schema/wire.d.ts +4 -5
- package/dist/types/utils.d.ts +9 -0
- package/package.json +6 -6
- package/src/auth-broker/remote-store.ts +73 -8
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/server.ts +48 -11
- package/src/auth-gateway/session-state.ts +114 -0
- package/src/auth-storage.ts +144 -13
- package/src/error/body-error.ts +310 -0
- package/src/error/flags.ts +63 -13
- package/src/error/index.ts +1 -0
- package/src/error/retryable.ts +2 -0
- package/src/oneshot-retry.ts +13 -3
- package/src/providers/anthropic-messages-server.ts +24 -3
- package/src/providers/anthropic.ts +101 -15
- package/src/providers/cursor.ts +7 -1
- package/src/providers/devin.ts +82 -28
- package/src/providers/openai-chat-server.ts +4 -0
- package/src/providers/openai-codex/request-transformer.ts +36 -0
- package/src/providers/openai-codex-responses.ts +35 -12
- package/src/providers/openai-completions.ts +43 -12
- package/src/providers/openai-reasoning-fallback.ts +6 -6
- package/src/providers/openai-responses-server.ts +2 -1
- package/src/providers/openai-responses.ts +25 -4
- package/src/providers/openai-shared.ts +199 -51
- package/src/registry/oauth/perplexity.ts +94 -28
- package/src/registry/oauth/types.ts +9 -0
- package/src/stream.ts +23 -2
- package/src/types.ts +3 -0
- package/src/usage/claude.ts +33 -0
- package/src/usage/google-antigravity.ts +8 -2
- package/src/usage.ts +3 -0
- package/src/utils/block-symbols.ts +57 -0
- package/src/utils/openai-http.ts +39 -3
- package/src/utils/retry-after.ts +12 -0
- package/src/utils/schema/normalize.ts +3 -3
- package/src/utils/schema/stamps.ts +33 -45
- package/src/utils/schema/wire.ts +9 -7
- package/src/utils.ts +67 -22
package/src/auth-storage.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { planRequirementFor } from "@oh-my-pi/pi-catalog/compat/behavior";
|
|
12
|
-
import { $env, $envExact,
|
|
12
|
+
import { $env, $envExact, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
|
|
13
13
|
import {
|
|
14
14
|
isSqliteCorruptionError,
|
|
15
15
|
resolveCredentialIdentityKey,
|
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
} from "./registry/oauth/types";
|
|
32
32
|
import { AUTHENTICATED_SENTINEL } from "./registry/types";
|
|
33
33
|
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
|
34
|
+
import { extractProviderRetryHint } from "./utils/retry-after";
|
|
34
35
|
import type { Provider } from "./types";
|
|
35
36
|
import type {
|
|
36
37
|
ClientUsageIdentity,
|
|
@@ -765,6 +766,11 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
|
|
|
765
766
|
* the usage report reveals. Callers that wait the account out (instead of
|
|
766
767
|
* rotating) must sleep until this, not the error-text hint alone.
|
|
767
768
|
*
|
|
769
|
+
* `requestedBlockedUntilMs` (epoch ms) is this mark call's initial deadline,
|
|
770
|
+
* before usage-report correction and longest-wins merging. Callers use it to
|
|
771
|
+
* distinguish the call's replaceable heuristic from a longer merged block
|
|
772
|
+
* that credential selection will continue enforcing.
|
|
773
|
+
*
|
|
768
774
|
* `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
|
|
769
775
|
* stored for this credential before this call. The merged `blockedUntilMs`
|
|
770
776
|
* masks a pre-existing block shorter than this call's own heuristic
|
|
@@ -790,6 +796,8 @@ export interface UsageLimitMarkResult {
|
|
|
790
796
|
switched: boolean;
|
|
791
797
|
retryAtMs?: number;
|
|
792
798
|
blockedUntilMs?: number;
|
|
799
|
+
/** This mark call's initial deadline, before report correction and merging. */
|
|
800
|
+
requestedBlockedUntilMs?: number;
|
|
793
801
|
priorBlockedUntilMs?: number;
|
|
794
802
|
priorBlockedUntilTimed?: boolean;
|
|
795
803
|
reportResetAtMs?: number;
|
|
@@ -1353,7 +1361,7 @@ export class AuthStorage {
|
|
|
1353
1361
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
1354
1362
|
#sessionLastCredential: Map<
|
|
1355
1363
|
string,
|
|
1356
|
-
Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
|
|
1364
|
+
Map<string, { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number }>
|
|
1357
1365
|
> = new Map();
|
|
1358
1366
|
/** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
|
|
1359
1367
|
#oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
|
|
@@ -1490,6 +1498,36 @@ export class AuthStorage {
|
|
|
1490
1498
|
return true;
|
|
1491
1499
|
}
|
|
1492
1500
|
|
|
1501
|
+
/**
|
|
1502
|
+
* Adopt credentials another process committed before selecting or rotating.
|
|
1503
|
+
*
|
|
1504
|
+
* The store is shared across every omp process, but the pool is an
|
|
1505
|
+
* in-process cache refreshed only by this process's own writes. Without
|
|
1506
|
+
* this a long-running session ranks a stale pool for its whole lifetime:
|
|
1507
|
+
* `omp auth` in another terminal is invisible, rotation reports no usable
|
|
1508
|
+
* sibling while a freshly added account sits unblocked in SQLite, and the
|
|
1509
|
+
* turn degrades to the fallback chain. The auth-broker path already polls;
|
|
1510
|
+
* direct-store sessions had no equivalent.
|
|
1511
|
+
*
|
|
1512
|
+
* A poll is two cheap reads (`PRAGMA data_version` plus the auth revision)
|
|
1513
|
+
* and re-lists credentials only when another connection committed, so it
|
|
1514
|
+
* runs on every resolution rather than on a timer that would make recovery
|
|
1515
|
+
* depend on wall-clock spacing. It sits on the paths that read the pool —
|
|
1516
|
+
* OAuth selection, and the two public usage-limit entry points — and is
|
|
1517
|
+
* idempotent, so a rotation reached through `markUsageLimitReached` costs
|
|
1518
|
+
* one extra `data_version` read and no second reload.
|
|
1519
|
+
*/
|
|
1520
|
+
async #adoptExternalCredentialChanges(): Promise<void> {
|
|
1521
|
+
if (this.#closed || this.#store.pollExternalChanges === undefined) return;
|
|
1522
|
+
try {
|
|
1523
|
+
await this.pollExternalChanges();
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
// A failed poll must not fail credential resolution: the in-memory
|
|
1526
|
+
// pool is still serviceable, just possibly stale.
|
|
1527
|
+
logger.debug("External credential poll failed", { error: String(error) });
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1493
1531
|
onGenerationChanged(listener: (generation: number) => void): () => void {
|
|
1494
1532
|
this.#generationListeners.add(listener);
|
|
1495
1533
|
return () => {
|
|
@@ -2084,12 +2122,12 @@ export class AuthStorage {
|
|
|
2084
2122
|
): void {
|
|
2085
2123
|
if (!sessionId) return;
|
|
2086
2124
|
const nowMs = lastUsedAtMs ?? Date.now();
|
|
2125
|
+
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
|
|
2087
2126
|
const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
|
|
2088
|
-
sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
|
|
2127
|
+
sessionMap.set(sessionId, { type, index, credentialId, lastUsedAtMs: nowMs });
|
|
2089
2128
|
this.#sessionLastCredential.set(provider, sessionMap);
|
|
2090
2129
|
|
|
2091
2130
|
try {
|
|
2092
|
-
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
|
|
2093
2131
|
if (credentialId !== undefined) {
|
|
2094
2132
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
2095
2133
|
const cacheValue = JSON.stringify({
|
|
@@ -2111,11 +2149,24 @@ export class AuthStorage {
|
|
|
2111
2149
|
#getSessionCredential(
|
|
2112
2150
|
provider: string,
|
|
2113
2151
|
sessionId: string | undefined,
|
|
2114
|
-
): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
|
|
2152
|
+
): { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number } | undefined {
|
|
2115
2153
|
if (!sessionId) return undefined;
|
|
2116
2154
|
let sessionMap = this.#sessionLastCredential.get(provider);
|
|
2117
|
-
|
|
2118
|
-
|
|
2155
|
+
const live = sessionMap?.get(sessionId);
|
|
2156
|
+
if (live) {
|
|
2157
|
+
// Another process can add or drop rows mid-session and the pool is an
|
|
2158
|
+
// index-ordered snapshot, so re-resolve the pin through its durable row
|
|
2159
|
+
// id: a compacted array must not point the session at a different
|
|
2160
|
+
// account, and a deleted account must not hand its slot to a sibling.
|
|
2161
|
+
if (live.credentialId === undefined) return live;
|
|
2162
|
+
const stored = this.#getStoredCredentials(provider);
|
|
2163
|
+
const actualIndex = stored.findIndex(entry => entry.id === live.credentialId);
|
|
2164
|
+
if (actualIndex === -1 || stored[actualIndex]?.credential.type !== live.type) {
|
|
2165
|
+
sessionMap?.delete(sessionId);
|
|
2166
|
+
return undefined;
|
|
2167
|
+
}
|
|
2168
|
+
live.index = actualIndex;
|
|
2169
|
+
return live;
|
|
2119
2170
|
}
|
|
2120
2171
|
try {
|
|
2121
2172
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
@@ -2149,6 +2200,7 @@ export class AuthStorage {
|
|
|
2149
2200
|
const sessionVal = {
|
|
2150
2201
|
type: val.type,
|
|
2151
2202
|
index: val.index,
|
|
2203
|
+
credentialId: val.credentialId,
|
|
2152
2204
|
lastUsedAtMs: val.lastUsedAtMs,
|
|
2153
2205
|
};
|
|
2154
2206
|
sessionMap.set(sessionId, sessionVal);
|
|
@@ -3145,6 +3197,7 @@ export class AuthStorage {
|
|
|
3145
3197
|
onProgress: ctrl.onProgress,
|
|
3146
3198
|
onPrompt: ctrl.onPrompt,
|
|
3147
3199
|
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
3200
|
+
onBrowserSession: ctrl.onBrowserSession,
|
|
3148
3201
|
signal: ctrl.signal,
|
|
3149
3202
|
fetch: ctrl.fetch,
|
|
3150
3203
|
});
|
|
@@ -4204,7 +4257,14 @@ export class AuthStorage {
|
|
|
4204
4257
|
const credentialType = entry.credential.type;
|
|
4205
4258
|
const providerKey = this.#getProviderTypeKey(provider, credentialType);
|
|
4206
4259
|
let blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
|
|
4207
|
-
|
|
4260
|
+
// A block under a scope the strategy can vouch for must still fetch
|
|
4261
|
+
// a probe report, or it outlives the recovery that report would
|
|
4262
|
+
// prove: no report means no reconciliation, so the credential idles
|
|
4263
|
+
// until the clock runs out even after quota is restored.
|
|
4264
|
+
if (
|
|
4265
|
+
blockedUntil !== undefined &&
|
|
4266
|
+
!this.#blockedCredentialCanHeal(provider, providerKey, index, blockScopes)
|
|
4267
|
+
) {
|
|
4208
4268
|
return {
|
|
4209
4269
|
credentialId: entry.id,
|
|
4210
4270
|
credentialType,
|
|
@@ -4231,7 +4291,7 @@ export class AuthStorage {
|
|
|
4231
4291
|
planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement));
|
|
4232
4292
|
}
|
|
4233
4293
|
|
|
4234
|
-
if (provider
|
|
4294
|
+
if (this.#supportsUsageBlockHealing(provider)) {
|
|
4235
4295
|
blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
|
|
4236
4296
|
}
|
|
4237
4297
|
if (blockedUntil !== undefined) {
|
|
@@ -4795,6 +4855,7 @@ export class AuthStorage {
|
|
|
4795
4855
|
signal?: AbortSignal;
|
|
4796
4856
|
},
|
|
4797
4857
|
): Promise<UsageLimitMarkResult> {
|
|
4858
|
+
await this.#adoptExternalCredentialChanges();
|
|
4798
4859
|
let sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
|
|
4799
4860
|
credentialId: options?.credentialId,
|
|
4800
4861
|
apiKey: options?.apiKey,
|
|
@@ -4820,7 +4881,8 @@ export class AuthStorage {
|
|
|
4820
4881
|
|
|
4821
4882
|
const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
|
|
4822
4883
|
const now = Date.now();
|
|
4823
|
-
|
|
4884
|
+
const requestedBlockedUntilMs = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
|
4885
|
+
let blockedUntil = requestedBlockedUntilMs;
|
|
4824
4886
|
// Heuristic/default fallbacks are guesses; provider-stated hints and
|
|
4825
4887
|
// report-derived extensions are timed.
|
|
4826
4888
|
let providerTimed = options?.providerTimed === true;
|
|
@@ -4871,7 +4933,11 @@ export class AuthStorage {
|
|
|
4871
4933
|
routing,
|
|
4872
4934
|
providerTimed,
|
|
4873
4935
|
);
|
|
4874
|
-
return
|
|
4936
|
+
return {
|
|
4937
|
+
...rotation,
|
|
4938
|
+
requestedBlockedUntilMs,
|
|
4939
|
+
...(reportResetAtMs === undefined ? {} : { reportResetAtMs }),
|
|
4940
|
+
};
|
|
4875
4941
|
}
|
|
4876
4942
|
|
|
4877
4943
|
#resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
|
|
@@ -5011,7 +5077,15 @@ export class AuthStorage {
|
|
|
5011
5077
|
);
|
|
5012
5078
|
let usage: UsageReport | null = null;
|
|
5013
5079
|
let usageChecked = false;
|
|
5014
|
-
if (
|
|
5080
|
+
if (
|
|
5081
|
+
blockedUntil !== undefined &&
|
|
5082
|
+
this.#blockedCredentialCanHeal(
|
|
5083
|
+
args.provider,
|
|
5084
|
+
args.providerKey,
|
|
5085
|
+
selection.index,
|
|
5086
|
+
args.blockScopes ?? args.blockScope,
|
|
5087
|
+
)
|
|
5088
|
+
) {
|
|
5015
5089
|
usage = await this.#getUsageReport(args.provider, selection.credential, {
|
|
5016
5090
|
...args.options,
|
|
5017
5091
|
timeoutMs: this.#usageRequestTimeoutMs,
|
|
@@ -5136,6 +5210,7 @@ export class AuthStorage {
|
|
|
5136
5210
|
sessionId?: string,
|
|
5137
5211
|
options?: AuthApiKeyOptions,
|
|
5138
5212
|
): Promise<OAuthResolutionResult | undefined> {
|
|
5213
|
+
await this.#adoptExternalCredentialChanges();
|
|
5139
5214
|
const credentials = this.#getCredentialsForProvider(provider)
|
|
5140
5215
|
.map((credential, index) => ({ credential, index }))
|
|
5141
5216
|
.filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
|
|
@@ -5889,6 +5964,8 @@ export class AuthStorage {
|
|
|
5889
5964
|
return configKey;
|
|
5890
5965
|
}
|
|
5891
5966
|
|
|
5967
|
+
await this.#adoptExternalCredentialChanges();
|
|
5968
|
+
|
|
5892
5969
|
// Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
|
|
5893
5970
|
// then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
|
|
5894
5971
|
const oauthSelection = this.#selectCredentialByType(provider, "oauth");
|
|
@@ -6164,6 +6241,32 @@ export class AuthStorage {
|
|
|
6164
6241
|
return true;
|
|
6165
6242
|
}
|
|
6166
6243
|
|
|
6244
|
+
/**
|
|
6245
|
+
* Copy every stored credential affinity from one live session to another.
|
|
6246
|
+
*
|
|
6247
|
+
* The target receives its own sticky entries, so request resolution, usage
|
|
6248
|
+
* blocking, credential rotation, metadata, and persisted pins all continue
|
|
6249
|
+
* through the target session id without retaining a live dependency on the
|
|
6250
|
+
* source session.
|
|
6251
|
+
*/
|
|
6252
|
+
inheritSessionCredentials(sourceSessionId: string, targetSessionId: string): number {
|
|
6253
|
+
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) return 0;
|
|
6254
|
+
let inherited = 0;
|
|
6255
|
+
for (const provider of this.#data.keys()) {
|
|
6256
|
+
const credential = this.#getSessionCredential(provider, sourceSessionId);
|
|
6257
|
+
if (!credential) continue;
|
|
6258
|
+
this.#recordSessionCredential(
|
|
6259
|
+
provider,
|
|
6260
|
+
targetSessionId,
|
|
6261
|
+
credential.type,
|
|
6262
|
+
credential.index,
|
|
6263
|
+
credential.lastUsedAtMs,
|
|
6264
|
+
);
|
|
6265
|
+
inherited += 1;
|
|
6266
|
+
}
|
|
6267
|
+
return inherited;
|
|
6268
|
+
}
|
|
6269
|
+
|
|
6167
6270
|
/**
|
|
6168
6271
|
* Resolve every stored OAuth credential for `provider` independently.
|
|
6169
6272
|
*
|
|
@@ -6612,6 +6715,29 @@ export class AuthStorage {
|
|
|
6612
6715
|
);
|
|
6613
6716
|
}
|
|
6614
6717
|
|
|
6718
|
+
/**
|
|
6719
|
+
* Whether a fresh report could lift what currently blocks this credential.
|
|
6720
|
+
*
|
|
6721
|
+
* A strategy that names healable scopes can only vouch for those scopes, so
|
|
6722
|
+
* a live unscoped block — an Opus/Sonnet usage limit, a refresh failure —
|
|
6723
|
+
* keeps the credential unusable whatever the report says about a tier. A
|
|
6724
|
+
* probe then cannot change the outcome and must not be spent; the tier scope
|
|
6725
|
+
* heals on a later pass, once the block that actually holds the credential
|
|
6726
|
+
* has lifted. Codex heals through its meter metadata rather than named
|
|
6727
|
+
* scopes, so its blocks always qualify.
|
|
6728
|
+
*/
|
|
6729
|
+
#blockedCredentialCanHeal(
|
|
6730
|
+
provider: Provider,
|
|
6731
|
+
providerKey: string,
|
|
6732
|
+
credentialIndex: number,
|
|
6733
|
+
blockScopeOrScopes: string | readonly string[] | undefined,
|
|
6734
|
+
): boolean {
|
|
6735
|
+
if (!this.#supportsUsageBlockHealing(provider)) return false;
|
|
6736
|
+
if (this.#rankingStrategyResolver?.(provider)?.healableBlockScopes === undefined) return true;
|
|
6737
|
+
if (this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex) !== undefined) return false;
|
|
6738
|
+
return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScopeOrScopes) !== undefined;
|
|
6739
|
+
}
|
|
6740
|
+
|
|
6615
6741
|
/**
|
|
6616
6742
|
* Self-heal stale usage-limit blocks: when a fresh live usage report says a
|
|
6617
6743
|
* scope is below every limit gating it, drop its persisted and in-memory
|
|
@@ -6626,6 +6752,10 @@ export class AuthStorage {
|
|
|
6626
6752
|
if (credentialIndex < 0) return;
|
|
6627
6753
|
const strategy = this.#rankingStrategyResolver?.(provider);
|
|
6628
6754
|
if (provider !== "openai-codex") {
|
|
6755
|
+
// Only a live report proves recovery. A broker can serve its retained
|
|
6756
|
+
// last-good report for hours after `/usage` starts failing, and those
|
|
6757
|
+
// healthy limits describe the account before the 429 that blocked it.
|
|
6758
|
+
if (!Number.isFinite(report.fetchedAt) || Date.now() - report.fetchedAt > USAGE_REPORT_TTL_MS) return;
|
|
6629
6759
|
for (const { blockScope, limits } of strategy?.healableBlockScopes?.(report) ?? []) {
|
|
6630
6760
|
if (limits.length === 0 || this.#isUsageLimitReached(limits)) continue;
|
|
6631
6761
|
this.#clearHealedBlockScope(provider, providerKey, credentialId, credentialIndex, blockScope);
|
|
@@ -6861,6 +6991,7 @@ export class AuthStorage {
|
|
|
6861
6991
|
signal?: AbortSignal;
|
|
6862
6992
|
},
|
|
6863
6993
|
): Promise<boolean> {
|
|
6994
|
+
await this.#adoptExternalCredentialChanges();
|
|
6864
6995
|
const error = options?.error;
|
|
6865
6996
|
const status = AIError.status(error);
|
|
6866
6997
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
@@ -6870,7 +7001,7 @@ export class AuthStorage {
|
|
|
6870
7001
|
// Thread the provider-specified reset window (e.g. Devin "Your limit
|
|
6871
7002
|
// will reset in 13 minutes") into the block duration so the credential
|
|
6872
7003
|
// is not reselected and hammered while the cap remains active.
|
|
6873
|
-
const retryAfterMs =
|
|
7004
|
+
const retryAfterMs = extractProviderRetryHint(provider, message);
|
|
6874
7005
|
return (
|
|
6875
7006
|
await this.markUsageLimitReached(provider, sessionId, {
|
|
6876
7007
|
retryAfterMs,
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-band provider failures: upstream 429/5xx payloads that arrive inside an
|
|
3
|
+
* HTTP 200 response body, or mid-stream after the SSE headers were already sent.
|
|
4
|
+
*
|
|
5
|
+
* Providers that front a retry/queue layer — Azure OpenAI, LiteLLM-style
|
|
6
|
+
* aggregators, Bedrock-compatible shims — answer a throttled request with
|
|
7
|
+
* `200 OK` + `text/event-stream` and put the real status in the payload:
|
|
8
|
+
* `data: {"error":{"type":"rate_limit_error"}}`, `data: {"code":429}`, or a bare
|
|
9
|
+
* non-JSON frame such as `data: 429 Too Many Requests` / an nginx throttle page.
|
|
10
|
+
* Those bodies used to be either dropped silently (the stream then looked like a
|
|
11
|
+
* successful empty completion) or surfaced as an unclassified
|
|
12
|
+
* {@link ProviderResponseError} whose `errorId` stayed 0 — so `AIError.retriable`
|
|
13
|
+
* answered "terminal" and `retry.fallbackChains` never advanced, pinning the
|
|
14
|
+
* session on a provider that was merely busy.
|
|
15
|
+
*
|
|
16
|
+
* Both probes hand the classifier the structured signal it already trusts for an
|
|
17
|
+
* out-of-band failure: a {@link ProviderHttpError} carrying a status the upstream
|
|
18
|
+
* *reported*, so the two routes reach one code path. The invariants that make
|
|
19
|
+
* that safe, and that callers rely on:
|
|
20
|
+
*
|
|
21
|
+
* - **No invented HTTP metadata.** A status is taken only from an error
|
|
22
|
+
* `status`/`code` *field* (numeric, or a 3-digit string as compat hosts send)
|
|
23
|
+
* or from {@link RETRYABLE_STATUS_BY_CODE}, and only for `429`/`5xx`. A status
|
|
24
|
+
* mentioned inside error prose is never promoted to a status: 401/403 wording
|
|
25
|
+
* stays text and cannot route the failure into the auth-retry lane.
|
|
26
|
+
* - **No credential rotation on an unreadable body.** A `429` whose body is
|
|
27
|
+
* empty, `{}`, or framing-only is opaque, and an opaque 429 is the conservative
|
|
28
|
+
* rotate-to-a-sibling-credential signal. That verdict belongs to a body the
|
|
29
|
+
* server actually sent, not to a payload we synthesised, so
|
|
30
|
+
* {@link formatInBandMessage} substitutes {@link IN_BAND_DETAIL_PLACEHOLDER}
|
|
31
|
+
* and the composed message always stays informative.
|
|
32
|
+
* - **Unknown envelopes fall through.** Anything without a retryable status
|
|
33
|
+
* field, a retryable code, or unambiguous throttle wording returns
|
|
34
|
+
* `undefined` and keeps its pre-existing handling and message.
|
|
35
|
+
*
|
|
36
|
+
* Where no numeric status exists, the returned error keeps the upstream wording
|
|
37
|
+
* and code visible in its message instead of asserting HTTP metadata the provider
|
|
38
|
+
* never sent, and carries {@link Flag.Transient} directly: throttle spellings like
|
|
39
|
+
* `Throttled` / `Please retry` are what this probe recognises but the transport
|
|
40
|
+
* text pattern is not required to match, so the retry decision is stated rather
|
|
41
|
+
* than hoped for.
|
|
42
|
+
*/
|
|
43
|
+
import { ProviderHttpError } from "./classes";
|
|
44
|
+
import { attach, create, Flag } from "./flags";
|
|
45
|
+
import { isOpaqueStatusBody } from "./rate-limit";
|
|
46
|
+
import { ProviderResponseError } from "./provider";
|
|
47
|
+
|
|
48
|
+
/** Cap on synthesized message length, mirroring the transport-level `MAX_DETAIL_CHARS`. */
|
|
49
|
+
const MAX_IN_BAND_DETAIL_CHARS = 4096;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Filler used when an in-band failure frame carries no readable detail. It has
|
|
53
|
+
* to be informative prose on purpose: an opaque message on a `429` is read as
|
|
54
|
+
* "the server gave us nothing" and rotates a credential, and that judgement must
|
|
55
|
+
* never be triggered by wording of ours.
|
|
56
|
+
*/
|
|
57
|
+
const IN_BAND_DETAIL_PLACEHOLDER = "Provider returned an in-band provider error";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Machine error codes that mean "shed this request and back off", mapped to the
|
|
61
|
+
* HTTP status the upstream would have used had it not wrapped the failure in a
|
|
62
|
+
* 200. Keys are compared after lower-casing, splitting camel/Pascal word
|
|
63
|
+
* boundaries, and collapsing `_`/`-`/`.`/spaces, so `Throttling.AllocationQuota`
|
|
64
|
+
* and `ThrottlingAllocationQuota` both resolve. The list is deliberately limited
|
|
65
|
+
* to throttle/overload spellings so an unlisted code keeps its pre-existing
|
|
66
|
+
* classification: request-validation failures (`invalid_request_error`) and
|
|
67
|
+
* account caps (`insufficient_quota`, `usage_limit_reached`) are never
|
|
68
|
+
* reinterpreted as retries.
|
|
69
|
+
*/
|
|
70
|
+
const RETRYABLE_STATUS_BY_CODE: Record<string, number> = {
|
|
71
|
+
rate_limit_error: 429,
|
|
72
|
+
rate_limit_exceeded: 429,
|
|
73
|
+
rate_limit: 429,
|
|
74
|
+
rate_limit_reached: 429,
|
|
75
|
+
rate_limited: 429,
|
|
76
|
+
ratelimit: 429,
|
|
77
|
+
too_many_requests: 429,
|
|
78
|
+
request_throttled: 429,
|
|
79
|
+
throttled: 429,
|
|
80
|
+
throttling: 429,
|
|
81
|
+
throttling_error: 429,
|
|
82
|
+
throttling_exception: 429,
|
|
83
|
+
throttling_allocation_quota: 429,
|
|
84
|
+
request_limit_exceeded: 429,
|
|
85
|
+
retry_later: 429,
|
|
86
|
+
overloaded_error: 503,
|
|
87
|
+
server_overloaded: 503,
|
|
88
|
+
model_overloaded: 503,
|
|
89
|
+
overloaded: 503,
|
|
90
|
+
service_unavailable: 503,
|
|
91
|
+
server_busy: 503,
|
|
92
|
+
high_demand: 503,
|
|
93
|
+
capacity_exceeded: 503,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Wording that identifies a throttle/overload in an error code or body. Kept as
|
|
98
|
+
* an explicit list rather than reusing the classifier's pattern, so this probe
|
|
99
|
+
* stays independent of the text rules it feeds.
|
|
100
|
+
*/
|
|
101
|
+
const IN_BAND_RETRYABLE_TEXT_PATTERN =
|
|
102
|
+
/\brate.?limit|too many requests|too\s+many\s+concurren|service.{0,20}unavailable|temporarily\s+unavailable|server.?error|internal.?error|overloaded|capacity|throttl|retry\s+(?:your\s+)?request|please\s+retry/i;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A status in the position a proxy error page puts it: the very first token,
|
|
106
|
+
* optionally after an `HTTP/1.1 ` prefix. Delimited by a word boundary so
|
|
107
|
+
* identifiers (`chatcmpl-500321`, `gpt-500x`, `req500502`) cannot fabricate a
|
|
108
|
+
* status — the same hazard `error-transient-status-boundary.test.ts` guards.
|
|
109
|
+
* Prose that merely *mentions* a number (`Too many requests (401 from …)`) is
|
|
110
|
+
* deliberately not read as status metadata.
|
|
111
|
+
*/
|
|
112
|
+
const LEADING_STATUS_PATTERN = /^\s*(?:HTTP[/.]\d(?:\.\d)?\s+)?([45]\d{2})(?:\b|$)/i;
|
|
113
|
+
|
|
114
|
+
/** Codes that mean a persistent account/billing cap or a bad request; never shed-and-retry. */
|
|
115
|
+
const NON_RETRYABLE_CODE_PATTERN =
|
|
116
|
+
/insufficient.?quota|usage.?limit|quota.?(?:exceeded|reached|insufficient)|invalid_request|content_filter|context_length|context_window|billing|balance/i;
|
|
117
|
+
|
|
118
|
+
/** Flags this module asserts for a body it has itself recognised as shed-and-retry. */
|
|
119
|
+
const IN_BAND_FLAGS = create(Flag.Transient);
|
|
120
|
+
|
|
121
|
+
function normalizeCodeToken(value: unknown): string | undefined {
|
|
122
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
123
|
+
if (typeof value !== "string") return undefined;
|
|
124
|
+
const spaced = value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2");
|
|
125
|
+
const collapsed = spaced
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[-.\s]+/g, "_")
|
|
128
|
+
.replace(/_+/g, "_");
|
|
129
|
+
return collapsed.length > 0 ? collapsed : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readInBandDetail(value: unknown): string | undefined {
|
|
133
|
+
if (typeof value !== "string") return undefined;
|
|
134
|
+
// SSE joins multi-line `data:` values with `\n`, and proxy failures arrive as
|
|
135
|
+
// HTML: flatten to one line of visible text so a synthesized message cannot
|
|
136
|
+
// smuggle markup or framing into the classifier or the terminal.
|
|
137
|
+
const flattened = value
|
|
138
|
+
.replace(/<[^>]*>/g, " ")
|
|
139
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
140
|
+
.replace(/\s+/g, " ")
|
|
141
|
+
.trim();
|
|
142
|
+
if (flattened.length === 0) return undefined;
|
|
143
|
+
return flattened.length > MAX_IN_BAND_DETAIL_CHARS ? flattened.slice(0, MAX_IN_BAND_DETAIL_CHARS) : flattened;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A numeric HTTP status field, accepting the `"429"` string form compat hosts emit. */
|
|
147
|
+
function readStatusField(value: unknown): number | undefined {
|
|
148
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) return value;
|
|
149
|
+
if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
|
|
150
|
+
const parsed = Number(value.trim());
|
|
151
|
+
return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : undefined;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Only `429` and genuine server faults are shed-and-retry; 4xx of any other kind is not. */
|
|
157
|
+
function isRetryableStatus(status: number | undefined): status is number {
|
|
158
|
+
return status === 429 || (status !== undefined && status >= 500 && status <= 599);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readLeadingStatus(text: string | undefined): number | undefined {
|
|
162
|
+
if (text === undefined) return undefined;
|
|
163
|
+
const match = LEADING_STATUS_PATTERN.exec(text);
|
|
164
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface InBandSignal {
|
|
168
|
+
/** Numeric HTTP status the upstream reported, or implied by its error code. */
|
|
169
|
+
status?: number;
|
|
170
|
+
/** Machine code from the body (`error.code` preferred over `error.type`). */
|
|
171
|
+
code?: string;
|
|
172
|
+
/** Human-readable detail from the body. */
|
|
173
|
+
detail?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pull the failure signal out of an OpenAI-wire frame. Accepts the nested
|
|
178
|
+
* `{ error: { code, type, status, message } }` shape, the `response.error`
|
|
179
|
+
* position of Responses-API terminal events, the flat `{ code, status, message }`
|
|
180
|
+
* bodies compat hosts emit, and string envelopes (`{ error: "..." }`).
|
|
181
|
+
*
|
|
182
|
+
* `undefined` means "not an in-band failure worth retrying". The probe is
|
|
183
|
+
* intentionally narrow: a bare `type` is present on every Responses event and so
|
|
184
|
+
* never counts as a signal by itself, statuses come only from fields (never from
|
|
185
|
+
* prose), and a frame with neither a retryable status nor throttle wording is
|
|
186
|
+
* left to the caller's existing handling.
|
|
187
|
+
*/
|
|
188
|
+
function readInBandSignal(frame: unknown): InBandSignal | undefined {
|
|
189
|
+
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) return undefined;
|
|
190
|
+
const root = frame as Record<string, unknown>;
|
|
191
|
+
const nested = root.error ?? (root.response as Record<string, unknown> | undefined)?.error;
|
|
192
|
+
// Azure-compatible gates double-wrap (`{ error: { error: { code, message } } }`).
|
|
193
|
+
// Walk at most two levels so a deep payload cannot extend the parse.
|
|
194
|
+
let error: Record<string, unknown> | undefined;
|
|
195
|
+
if (typeof nested === "object" && nested !== null) {
|
|
196
|
+
error = nested as Record<string, unknown>;
|
|
197
|
+
for (let depth = 0; depth < 2; depth++) {
|
|
198
|
+
const inner = error.error;
|
|
199
|
+
if (typeof inner !== "object" || inner === null) break;
|
|
200
|
+
error = inner as Record<string, unknown>;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const code = normalizeCodeToken(error?.code ?? root.code) ?? normalizeCodeToken(error?.type ?? root.type);
|
|
204
|
+
if (code !== undefined && NON_RETRYABLE_CODE_PATTERN.test(code)) return undefined;
|
|
205
|
+
// A flat retryable code/type is itself an in-band failure: Responses-API
|
|
206
|
+
// `error` events expose `{ type: "rate_limit_error" }` with no `error` member,
|
|
207
|
+
// and their handler passes the inner error object (not the whole event).
|
|
208
|
+
const retryableCode = code !== undefined && IN_BAND_RETRYABLE_TEXT_PATTERN.test(code);
|
|
209
|
+
// Only an explicit error member, a top-level status/code/message field, or a
|
|
210
|
+
// standalone throttle type can qualify a frame as a failure; ordinary chunks
|
|
211
|
+
// carry none of these. A bare `type` is present on every Responses event, so
|
|
212
|
+
// it only counts when it is itself retryable wording.
|
|
213
|
+
if (
|
|
214
|
+
!retryableCode &&
|
|
215
|
+
nested === undefined &&
|
|
216
|
+
root.status === undefined &&
|
|
217
|
+
root.code === undefined &&
|
|
218
|
+
root.message === undefined
|
|
219
|
+
) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
const detail =
|
|
223
|
+
readInBandDetail(error?.message) ??
|
|
224
|
+
readInBandDetail(root.message) ??
|
|
225
|
+
(typeof nested === "string" ? readInBandDetail(nested) : undefined);
|
|
226
|
+
const holder = error ?? root;
|
|
227
|
+
// Reported statuses come from fields only: the error member's `status`, its
|
|
228
|
+
// numeric `code`, then the same on the root (the flat `{ code: 429 }` /
|
|
229
|
+
// `{ status: 429 }` bodies compat hosts emit). Anything outside 429/5xx is
|
|
230
|
+
// ignored outright — an in-band `400`/`401`/`403` field must not become a
|
|
231
|
+
// synthetic HTTP contract, or a body that merely names an auth problem would
|
|
232
|
+
// route into the credential lane.
|
|
233
|
+
const reported = holder === root ? [root.status, root.code] : [holder.status, holder.code, root.status, root.code];
|
|
234
|
+
const status =
|
|
235
|
+
reported.map(readStatusField).find(isRetryableStatus) ??
|
|
236
|
+
(code !== undefined && Object.hasOwn(RETRYABLE_STATUS_BY_CODE, code)
|
|
237
|
+
? readStatusField(RETRYABLE_STATUS_BY_CODE[code])
|
|
238
|
+
: undefined);
|
|
239
|
+
if (status !== undefined) return { status, code, detail };
|
|
240
|
+
// No reported status: classify only when the upstream *message* is itself
|
|
241
|
+
// unambiguous throttle wording. A generic code alone must not qualify —
|
|
242
|
+
// Azure uses `server_error` for terminal backend failures whose
|
|
243
|
+
// `"<code>: <message>"` envelope the caller already reports (and whose text
|
|
244
|
+
// the shared transient rule already matches), so intercepting it would
|
|
245
|
+
// change an established error format without adding retry information.
|
|
246
|
+
if (detail === undefined || !IN_BAND_RETRYABLE_TEXT_PATTERN.test(detail)) return undefined;
|
|
247
|
+
return { code, detail };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Compose the message for a status-bearing in-band failure. The numeric status
|
|
252
|
+
* leads (matching `captureOpenAIHttpError`'s `"<status> <detail>"` phrasing)
|
|
253
|
+
* unless the detail already carries it as its leading token; the machine code is
|
|
254
|
+
* appended only when it adds information the text classifier or a human reader
|
|
255
|
+
* can use. A detail that leaves the whole line opaque is replaced by the
|
|
256
|
+
* placeholder, so a body we generated can never be read as "the server said
|
|
257
|
+
* nothing".
|
|
258
|
+
*/
|
|
259
|
+
function formatInBandMessage(status: number, detail: string | undefined, code: string | undefined): string {
|
|
260
|
+
const body = detail ?? IN_BAND_DETAIL_PLACEHOLDER;
|
|
261
|
+
const suffix =
|
|
262
|
+
code !== undefined && !/^\d+$/.test(code) && !body.toLowerCase().includes(code.toLowerCase()) ? ` (${code})` : "";
|
|
263
|
+
let message = readLeadingStatus(body) === status ? body : `${status} ${body}`;
|
|
264
|
+
if (isOpaqueStatusBody(message)) message = `${status} ${IN_BAND_DETAIL_PLACEHOLDER}`;
|
|
265
|
+
return `${message}${suffix}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build the classified error for an in-band failure frame, or `undefined` when
|
|
270
|
+
* the frame is not a retryable in-band failure (in which case the caller keeps
|
|
271
|
+
* its existing handling and message).
|
|
272
|
+
*
|
|
273
|
+
* @param frame decoded SSE `data:` payload, or the `{ error, response }` subset of one
|
|
274
|
+
*/
|
|
275
|
+
export function createInBandProviderError(frame: unknown): Error | undefined {
|
|
276
|
+
const signal = readInBandSignal(frame);
|
|
277
|
+
if (!signal) return undefined;
|
|
278
|
+
const { status, code, detail } = signal;
|
|
279
|
+
if (isRetryableStatus(status)) {
|
|
280
|
+
return attach(new ProviderHttpError(formatInBandMessage(status, detail, code), status, { code }), IN_BAND_FLAGS);
|
|
281
|
+
}
|
|
282
|
+
if (detail === undefined && code === undefined) return undefined;
|
|
283
|
+
// Keep the upstream code visible (`(<code>)`) — it is real provider data and
|
|
284
|
+
// the same convention the Anthropic provider already uses for its
|
|
285
|
+
// `(<errorType>)` suffix.
|
|
286
|
+
return attach(
|
|
287
|
+
new ProviderResponseError(`${detail ?? IN_BAND_DETAIL_PLACEHOLDER}${code ? ` (${code})` : ""}`, {
|
|
288
|
+
kind: "runtime",
|
|
289
|
+
}),
|
|
290
|
+
IN_BAND_FLAGS,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build the classified error for a non-JSON SSE frame: gateways and reverse
|
|
296
|
+
* proxies that answer `data: 429 Too Many Requests` or an HTML throttle page
|
|
297
|
+
* instead of an OpenAI envelope. `undefined` when the text is not recognisable
|
|
298
|
+
* as a throttle, so genuinely malformed payloads keep failing loudly.
|
|
299
|
+
*/
|
|
300
|
+
export function createInBandProviderErrorFromText(text: string): Error | undefined {
|
|
301
|
+
const detail = readInBandDetail(text);
|
|
302
|
+
if (detail === undefined || !IN_BAND_RETRYABLE_TEXT_PATTERN.test(detail)) return undefined;
|
|
303
|
+
const status = readLeadingStatus(detail);
|
|
304
|
+
if (isRetryableStatus(status)) {
|
|
305
|
+
return attach(new ProviderHttpError(formatInBandMessage(status, detail, undefined), status), IN_BAND_FLAGS);
|
|
306
|
+
}
|
|
307
|
+
// A proxy status line with no machine code to preserve: report the upstream
|
|
308
|
+
// text verbatim rather than padding it with wording of ours.
|
|
309
|
+
return attach(new ProviderResponseError(detail, { kind: "runtime" }), IN_BAND_FLAGS);
|
|
310
|
+
}
|