@oh-my-pi/pi-ai 17.4.0 → 17.4.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 +30 -0
- package/LICENSE +23 -0
- package/THIRD-PARTY-NOTICES.txt +22909 -0
- package/dist/types/auth-retry.d.ts +8 -3
- package/dist/types/auth-storage.d.ts +9 -0
- package/dist/types/dialect/qwen-xml.d.ts +6 -0
- package/dist/types/error/auth-classify.d.ts +8 -8
- package/dist/types/error/oauth.d.ts +2 -1
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +21 -0
- package/dist/types/providers/cursor.d.ts +23 -3
- package/dist/types/providers/google-types.d.ts +6 -0
- package/dist/types/providers/mock.d.ts +3 -1
- package/dist/types/providers/openai-shared.d.ts +6 -1
- package/dist/types/registry/oauth/google-antigravity.d.ts +2 -6
- package/dist/types/registry/oauth/index.d.ts +2 -3
- package/dist/types/registry/oauth/types.d.ts +2 -1
- package/dist/types/registry/oauth/xai-oauth.d.ts +2 -2
- package/dist/types/registry/registry.d.ts +1 -1
- package/dist/types/registry/types.d.ts +2 -1
- package/dist/types/registry/xai-oauth.d.ts +1 -1
- package/dist/types/types.d.ts +21 -0
- package/dist/types/utils/stream-markup-healing.d.ts +1 -1
- package/package.json +8 -6
- package/src/auth-retry.ts +41 -3
- package/src/auth-storage.ts +229 -75
- package/src/dialect/qwen-xml.ts +82 -0
- package/src/error/auth-classify.ts +10 -8
- package/src/error/oauth.ts +2 -1
- package/src/providers/amazon-bedrock.ts +12 -19
- package/src/providers/anthropic.ts +29 -35
- package/src/providers/cursor/exec-modern.ts +2 -0
- package/src/providers/cursor-pi-args.ts +38 -2
- package/src/providers/cursor.ts +357 -32
- package/src/providers/google-shared.ts +11 -12
- package/src/providers/google-types.ts +7 -0
- package/src/providers/mock.ts +4 -1
- package/src/providers/openai-codex-responses.ts +34 -8
- package/src/providers/openai-completions.ts +2 -2
- package/src/providers/openai-shared.ts +72 -14
- package/src/registry/oauth/google-antigravity.ts +247 -162
- package/src/registry/oauth/index.ts +3 -3
- package/src/registry/oauth/types.ts +2 -1
- package/src/registry/oauth/xai-oauth.ts +14 -7
- package/src/registry/types.ts +2 -1
- package/src/registry/xai-oauth.ts +2 -2
- package/src/stream.ts +8 -4
- package/src/types.ts +22 -0
- package/src/utils/schema/CONSTRAINTS.md +1 -0
- package/src/utils/schema/fields.ts +20 -4
- package/src/utils/stream-markup-healing.ts +9 -2
package/src/auth-storage.ts
CHANGED
|
@@ -710,6 +710,7 @@ const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>
|
|
|
710
710
|
anthropic: 3,
|
|
711
711
|
};
|
|
712
712
|
const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
|
713
|
+
const OAUTH_REFRESH_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
|
|
713
714
|
/**
|
|
714
715
|
* Refresh OAuth access tokens this many ms before their stated expiry. The
|
|
715
716
|
* skew exists so callers downstream of {@link AuthStorage} (stream providers,
|
|
@@ -1313,6 +1314,9 @@ export class AuthStorage {
|
|
|
1313
1314
|
*/
|
|
1314
1315
|
#persistedBlockStoreDamaged = false;
|
|
1315
1316
|
#usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
1317
|
+
/** Runtime extension providers take precedence over this configured/default resolver. */
|
|
1318
|
+
#runtimeUsageProviderOverrides: Map<Provider, { provider: UsageProvider; apiKey?: string }> = new Map();
|
|
1319
|
+
#usageReportCacheKeysByProvider: Map<Provider, Set<string>> = new Map();
|
|
1316
1320
|
#rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
1317
1321
|
#usageCache: UsageCache;
|
|
1318
1322
|
#usageCacheEpoch = 0;
|
|
@@ -1492,6 +1496,26 @@ export class AuthStorage {
|
|
|
1492
1496
|
this.#runtimeOverrides.delete(provider);
|
|
1493
1497
|
}
|
|
1494
1498
|
|
|
1499
|
+
/**
|
|
1500
|
+
* Install a runtime usage provider override (not persisted to disk).
|
|
1501
|
+
*
|
|
1502
|
+
* Runtime overrides are checked before the configured resolver, including its
|
|
1503
|
+
* built-in fallback. Removing the override restores that resolver unchanged.
|
|
1504
|
+
*/
|
|
1505
|
+
setRuntimeUsageProvider(provider: Provider, usageProvider: UsageProvider, apiKey?: string): void {
|
|
1506
|
+
this.#runtimeUsageProviderOverrides.set(provider, { provider: usageProvider, apiKey });
|
|
1507
|
+
this.#invalidateUsageReportCacheForProvider(provider);
|
|
1508
|
+
}
|
|
1509
|
+
/** Remove a runtime usage provider override and restore configured/default resolution. */
|
|
1510
|
+
removeRuntimeUsageProvider(provider: Provider): void {
|
|
1511
|
+
if (!this.#runtimeUsageProviderOverrides.has(provider)) return;
|
|
1512
|
+
this.#invalidateUsageReportCacheForProvider(provider);
|
|
1513
|
+
this.#runtimeUsageProviderOverrides.delete(provider);
|
|
1514
|
+
}
|
|
1515
|
+
#resolveUsageProvider(provider: Provider): UsageProvider | undefined {
|
|
1516
|
+
return this.#runtimeUsageProviderOverrides.get(provider)?.provider ?? this.#usageProviderResolver?.(provider);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1495
1519
|
/**
|
|
1496
1520
|
* Register a per-provider API key sourced from user configuration
|
|
1497
1521
|
* (e.g. `models.yml` `providers.<name>.apiKey`). Higher priority than
|
|
@@ -3072,14 +3096,16 @@ export class AuthStorage {
|
|
|
3072
3096
|
this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), { value: null, expiresAt: 0 });
|
|
3073
3097
|
}
|
|
3074
3098
|
}
|
|
3075
|
-
|
|
3076
3099
|
#buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
|
|
3077
3100
|
const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
|
|
3078
3101
|
const identity = this.#buildUsageCacheIdentity(request.credential);
|
|
3079
3102
|
const providerKey = this.#usageCacheProviderKey(request.provider);
|
|
3080
|
-
|
|
3103
|
+
const cacheKey = `report:${providerKey}:${baseUrl}:${identity}`;
|
|
3104
|
+
const cacheKeys = this.#usageReportCacheKeysByProvider.get(request.provider) ?? new Set<string>();
|
|
3105
|
+
cacheKeys.add(cacheKey);
|
|
3106
|
+
this.#usageReportCacheKeysByProvider.set(request.provider, cacheKeys);
|
|
3107
|
+
return cacheKey;
|
|
3081
3108
|
}
|
|
3082
|
-
|
|
3083
3109
|
#buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
|
|
3084
3110
|
const snapshot = requests
|
|
3085
3111
|
.map(request => {
|
|
@@ -3216,10 +3242,7 @@ export class AuthStorage {
|
|
|
3216
3242
|
}
|
|
3217
3243
|
|
|
3218
3244
|
async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
|
|
3219
|
-
const
|
|
3220
|
-
if (!resolver) return null;
|
|
3221
|
-
|
|
3222
|
-
const providerImpl = resolver(request.provider);
|
|
3245
|
+
const providerImpl = this.#resolveUsageProvider(request.provider);
|
|
3223
3246
|
if (!providerImpl) return null;
|
|
3224
3247
|
|
|
3225
3248
|
const timeoutSignal =
|
|
@@ -3324,7 +3347,6 @@ export class AuthStorage {
|
|
|
3324
3347
|
return null;
|
|
3325
3348
|
}
|
|
3326
3349
|
}
|
|
3327
|
-
|
|
3328
3350
|
async #fetchUsageCached(
|
|
3329
3351
|
request: UsageRequestDescriptor,
|
|
3330
3352
|
options: { timeoutMs?: number; forceRefresh?: boolean } = {},
|
|
@@ -3332,6 +3354,7 @@ export class AuthStorage {
|
|
|
3332
3354
|
const timeoutMs = options.timeoutMs;
|
|
3333
3355
|
const forceRefresh = options.forceRefresh ?? false;
|
|
3334
3356
|
const cacheKey = this.#buildUsageReportCacheKey(request);
|
|
3357
|
+
|
|
3335
3358
|
const now = Date.now();
|
|
3336
3359
|
const cached = forceRefresh ? undefined : this.#usageCache.get<UsageReport | null>(cacheKey);
|
|
3337
3360
|
// Fresh cache hit: return whatever's there (success or null fallback).
|
|
@@ -3363,7 +3386,7 @@ export class AuthStorage {
|
|
|
3363
3386
|
// value through transient failures. Session-cookie providers can opt out
|
|
3364
3387
|
// so an expired login does not display stale quota indefinitely.
|
|
3365
3388
|
const retainLastGood =
|
|
3366
|
-
!forceRefresh && this.#
|
|
3389
|
+
!forceRefresh && this.#resolveUsageProvider(request.provider)?.retainLastGoodOnFailure !== false;
|
|
3367
3390
|
const lastGood = retainLastGood
|
|
3368
3391
|
? (this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
|
|
3369
3392
|
: null;
|
|
@@ -3378,7 +3401,6 @@ export class AuthStorage {
|
|
|
3378
3401
|
this.#usageRequestInFlight.set(inFlightKey, promise);
|
|
3379
3402
|
return promise;
|
|
3380
3403
|
}
|
|
3381
|
-
|
|
3382
3404
|
/**
|
|
3383
3405
|
* Append a freshly fetched report to durable usage history (when the store
|
|
3384
3406
|
* supports it). The usage cache is latest-snapshot-only — these rows are
|
|
@@ -3479,7 +3501,7 @@ export class AuthStorage {
|
|
|
3479
3501
|
options?: { sessionId?: string; baseUrl?: string },
|
|
3480
3502
|
): boolean {
|
|
3481
3503
|
if (this.#fetchUsageReportsOverride) return false;
|
|
3482
|
-
const parseHeaders = this.#
|
|
3504
|
+
const parseHeaders = this.#resolveUsageProvider(provider)?.parseRateLimitHeaders;
|
|
3483
3505
|
if (!parseHeaders) return false;
|
|
3484
3506
|
|
|
3485
3507
|
const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
|
|
@@ -3559,18 +3581,16 @@ export class AuthStorage {
|
|
|
3559
3581
|
async #collectUsageRequests(options?: {
|
|
3560
3582
|
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
3561
3583
|
}): Promise<UsageRequestDescriptor[]> {
|
|
3562
|
-
const resolver = this.#usageProviderResolver;
|
|
3563
|
-
if (!resolver) return [];
|
|
3564
|
-
|
|
3565
3584
|
const requests: UsageRequestDescriptor[] = [];
|
|
3566
3585
|
const providers = new Set<string>([
|
|
3567
3586
|
...this.#data.keys(),
|
|
3587
|
+
...this.#runtimeUsageProviderOverrides.keys(),
|
|
3568
3588
|
...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
|
|
3569
3589
|
]);
|
|
3570
3590
|
|
|
3571
3591
|
for (const providerId of providers) {
|
|
3572
3592
|
const provider = providerId as Provider;
|
|
3573
|
-
const providerImpl =
|
|
3593
|
+
const providerImpl = this.#resolveUsageProvider(provider);
|
|
3574
3594
|
if (!providerImpl) continue;
|
|
3575
3595
|
const baseUrl = options?.baseUrlResolver?.(provider);
|
|
3576
3596
|
let entries = this.#getStoredCredentials(providerId);
|
|
@@ -3607,8 +3627,12 @@ export class AuthStorage {
|
|
|
3607
3627
|
|
|
3608
3628
|
if (entries.length === 0) {
|
|
3609
3629
|
const runtimeKey = this.#runtimeOverrides.get(providerId);
|
|
3610
|
-
const
|
|
3611
|
-
|
|
3630
|
+
const extensionUsageKeyConfig = this.#runtimeUsageProviderOverrides.get(provider)?.apiKey,
|
|
3631
|
+
extensionUsageKey = extensionUsageKeyConfig
|
|
3632
|
+
? await this.#configValueResolver(extensionUsageKeyConfig)
|
|
3633
|
+
: undefined,
|
|
3634
|
+
envKey = getEnvApiKey(providerId);
|
|
3635
|
+
const apiKey = runtimeKey ?? extensionUsageKey ?? envKey;
|
|
3612
3636
|
if (!apiKey) continue;
|
|
3613
3637
|
const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl);
|
|
3614
3638
|
if (providerImpl.supports && !providerImpl.supports(request)) continue;
|
|
@@ -3864,7 +3888,7 @@ export class AuthStorage {
|
|
|
3864
3888
|
* providers without a usage API) — the latter never warrants a usage row.
|
|
3865
3889
|
*/
|
|
3866
3890
|
usageProviderFor(provider: Provider): UsageProvider | undefined {
|
|
3867
|
-
return this.#
|
|
3891
|
+
return this.#resolveUsageProvider(provider);
|
|
3868
3892
|
}
|
|
3869
3893
|
|
|
3870
3894
|
/**
|
|
@@ -4115,7 +4139,7 @@ export class AuthStorage {
|
|
|
4115
4139
|
if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
|
|
4116
4140
|
return reports;
|
|
4117
4141
|
}
|
|
4118
|
-
if (!this.#usageProviderResolver) return null;
|
|
4142
|
+
if (!this.#usageProviderResolver && this.#runtimeUsageProviderOverrides.size === 0) return null;
|
|
4119
4143
|
|
|
4120
4144
|
const requests = await this.#collectUsageRequests(options);
|
|
4121
4145
|
if (requests.length === 0) return [];
|
|
@@ -4206,7 +4230,7 @@ export class AuthStorage {
|
|
|
4206
4230
|
async checkCredentials(options?: CheckCredentialsOptions): Promise<CredentialHealthResult[]> {
|
|
4207
4231
|
options?.signal?.throwIfAborted();
|
|
4208
4232
|
const stored = this.#store.listAuthCredentials();
|
|
4209
|
-
|
|
4233
|
+
|
|
4210
4234
|
const timeoutMs = options?.timeoutMs ?? this.#usageRequestTimeoutMs;
|
|
4211
4235
|
const completionProbe = options?.completionProbe;
|
|
4212
4236
|
const completionTimeoutMs = options?.completionTimeoutMs ?? timeoutMs;
|
|
@@ -4307,7 +4331,7 @@ export class AuthStorage {
|
|
|
4307
4331
|
continue;
|
|
4308
4332
|
}
|
|
4309
4333
|
|
|
4310
|
-
const providerImpl =
|
|
4334
|
+
const providerImpl = this.#resolveUsageProvider(row.provider as Provider);
|
|
4311
4335
|
if (!providerImpl) {
|
|
4312
4336
|
base.reason = `no usage probe configured for provider ${row.provider}`;
|
|
4313
4337
|
} else if (providerImpl.supports && !providerImpl.supports(initialRequest)) {
|
|
@@ -4853,6 +4877,7 @@ export class AuthStorage {
|
|
|
4853
4877
|
.map(idx => credentials[idx])
|
|
4854
4878
|
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
|
|
4855
4879
|
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
|
4880
|
+
const preflightFailures = new Set<OAuthCandidate>();
|
|
4856
4881
|
|
|
4857
4882
|
// On the warm skip path the candidate list follows the round-robin `order`, not the pin, so
|
|
4858
4883
|
// hoist the pinned credential to the front to actually reuse it. When ranking ran, the pin is
|
|
@@ -4896,8 +4921,8 @@ export class AuthStorage {
|
|
|
4896
4921
|
candidate.selection.credential = latestCredential;
|
|
4897
4922
|
return;
|
|
4898
4923
|
}
|
|
4924
|
+
const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
|
|
4899
4925
|
try {
|
|
4900
|
-
const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
|
|
4901
4926
|
// Hand #refreshOAuthCredential a stale clone (expires:0) so its
|
|
4902
4927
|
// not-yet-expired short-circuit doesn't suppress the forced
|
|
4903
4928
|
// re-mint; an in-flight peer refresh is still awaited via the
|
|
@@ -4924,15 +4949,60 @@ export class AuthStorage {
|
|
|
4924
4949
|
this.#replaceCredentialAt(provider, candidate.selection.index, updated);
|
|
4925
4950
|
}
|
|
4926
4951
|
} catch (error) {
|
|
4927
|
-
//
|
|
4928
|
-
//
|
|
4929
|
-
|
|
4930
|
-
|
|
4952
|
+
// A failed preflight already exercised the provider refresh path.
|
|
4953
|
+
// Do not replay the same refresh token in the final candidate pass.
|
|
4954
|
+
const errorMsg = String(error);
|
|
4955
|
+
const isDefinitiveFailure = AIError.isDefinitiveOAuthFailure(errorMsg);
|
|
4931
4956
|
logger.debug("OAuth preflight refresh failed", {
|
|
4932
4957
|
provider,
|
|
4933
4958
|
index: candidate.selection.index,
|
|
4934
|
-
error:
|
|
4959
|
+
error: errorMsg,
|
|
4960
|
+
isDefinitiveFailure,
|
|
4935
4961
|
});
|
|
4962
|
+
if (isDefinitiveFailure) {
|
|
4963
|
+
// A dead grant discovered during preflight must be disabled here too —
|
|
4964
|
+
// the final candidate pass below skips every `preflightFailures` entry,
|
|
4965
|
+
// so if this branch only blocked the row (like the transient case), the
|
|
4966
|
+
// definitive failure would never reach `#tryOAuthCredential`'s own
|
|
4967
|
+
// disable logic and the row would be retried forever instead of torn down.
|
|
4968
|
+
const outcome = await this.#disableDefinitiveOAuthFailure(
|
|
4969
|
+
provider,
|
|
4970
|
+
credentialId,
|
|
4971
|
+
candidate.selection.credential,
|
|
4972
|
+
candidate.selection.index,
|
|
4973
|
+
errorMsg,
|
|
4974
|
+
);
|
|
4975
|
+
if (
|
|
4976
|
+
outcome !== "disabled" &&
|
|
4977
|
+
credentialId !== undefined &&
|
|
4978
|
+
this.#syncOAuthSelectionFromStore(provider, candidate.selection, credentialId)
|
|
4979
|
+
) {
|
|
4980
|
+
// A peer rotated this row (or won the disable CAS) between our
|
|
4981
|
+
// snapshot and the refresh; the helper reloaded storage and the row
|
|
4982
|
+
// still exists, so it now holds a valid, freshly rotated credential.
|
|
4983
|
+
// Re-sync the candidate onto it and leave it eligible so the final
|
|
4984
|
+
// pass retries with the live token instead of stranding it (mirrors
|
|
4985
|
+
// #tryOAuthCredential's peer-rotated re-resolve). If the peer instead
|
|
4986
|
+
// deleted/disabled the row, the re-sync fails and we fall through to
|
|
4987
|
+
// preflightFailures — leaving a stale index could rebind the candidate
|
|
4988
|
+
// to a sibling account with the wrong prefetched usage/plan.
|
|
4989
|
+
return;
|
|
4990
|
+
}
|
|
4991
|
+
} else if (credentialId !== undefined) {
|
|
4992
|
+
const latestIndex = this.#getStoredCredentials(provider).findIndex(
|
|
4993
|
+
entry => entry.id === credentialId,
|
|
4994
|
+
);
|
|
4995
|
+
if (latestIndex !== -1) {
|
|
4996
|
+
this.#markCredentialBlocked(
|
|
4997
|
+
provider,
|
|
4998
|
+
providerKey,
|
|
4999
|
+
latestIndex,
|
|
5000
|
+
Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
|
|
5001
|
+
blockScope,
|
|
5002
|
+
);
|
|
5003
|
+
}
|
|
5004
|
+
}
|
|
5005
|
+
preflightFailures.add(candidate);
|
|
4936
5006
|
}
|
|
4937
5007
|
}),
|
|
4938
5008
|
);
|
|
@@ -4975,6 +5045,7 @@ export class AuthStorage {
|
|
|
4975
5045
|
|
|
4976
5046
|
for (const pass of passes) {
|
|
4977
5047
|
for (const candidate of candidates) {
|
|
5048
|
+
if (preflightFailures.has(candidate)) continue;
|
|
4978
5049
|
const resolved = await this.#tryOAuthCredential(
|
|
4979
5050
|
provider,
|
|
4980
5051
|
candidate.selection,
|
|
@@ -5001,6 +5072,73 @@ export class AuthStorage {
|
|
|
5001
5072
|
return undefined;
|
|
5002
5073
|
}
|
|
5003
5074
|
|
|
5075
|
+
/**
|
|
5076
|
+
* Handles a definitively-dead OAuth grant discovered during refresh (`invalid_grant`,
|
|
5077
|
+
* `revoked`, …): checks for a peer rotation that raced the failure, then CAS-disables
|
|
5078
|
+
* the row and emits `credential_disabled`. Shared by the eager preflight refresh in
|
|
5079
|
+
* {@link AuthStorage.#resolveOAuthSelection} and the final-candidate refresh in
|
|
5080
|
+
* {@link AuthStorage.#tryOAuthCredential} so both actually disable the credential — not
|
|
5081
|
+
* just temporarily block it — on a definitive failure.
|
|
5082
|
+
*
|
|
5083
|
+
* Returns `"disabled"` once the row is torn down, `"peer-rotated"` when a concurrent
|
|
5084
|
+
* process refreshed the same row first (the persisted refresh token no longer matches
|
|
5085
|
+
* what we attempted — the caller should reload and retry with the new credential), or
|
|
5086
|
+
* `"cas-lost"` when the disable itself lost a race and the caller should reload before
|
|
5087
|
+
* continuing.
|
|
5088
|
+
*/
|
|
5089
|
+
async #disableDefinitiveOAuthFailure(
|
|
5090
|
+
provider: string,
|
|
5091
|
+
credentialId: number | undefined,
|
|
5092
|
+
attemptedCredential: OAuthCredential,
|
|
5093
|
+
index: number,
|
|
5094
|
+
errorMsg: string,
|
|
5095
|
+
): Promise<"disabled" | "peer-rotated" | "cas-lost"> {
|
|
5096
|
+
// The credential at this index may have been rotated by another process between
|
|
5097
|
+
// our in-memory snapshot and the refresh attempt: Anthropic rotates refresh
|
|
5098
|
+
// tokens on every use, so the peer's success leaves our stored token invalid.
|
|
5099
|
+
// Re-read the row from disk before marking it disabled — if the persisted
|
|
5100
|
+
// refresh token has changed, the peer rotation succeeded and we should pick
|
|
5101
|
+
// up the new credential instead of soft-deleting the row that the peer just
|
|
5102
|
+
// updated.
|
|
5103
|
+
if (credentialId !== undefined) {
|
|
5104
|
+
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
|
|
5105
|
+
const latestCredential = latestRow?.credential;
|
|
5106
|
+
if (latestCredential?.type === "oauth" && latestCredential.refresh !== attemptedCredential.refresh) {
|
|
5107
|
+
logger.debug("OAuth refresh race detected; another process rotated token first", {
|
|
5108
|
+
provider,
|
|
5109
|
+
index,
|
|
5110
|
+
credentialId,
|
|
5111
|
+
});
|
|
5112
|
+
await this.reload();
|
|
5113
|
+
return "peer-rotated";
|
|
5114
|
+
}
|
|
5115
|
+
}
|
|
5116
|
+
// Permanently disable invalid credentials with an explicit cause for inspection/debugging.
|
|
5117
|
+
// Use a CAS-style disable conditioned on the row still containing the stale credential
|
|
5118
|
+
// we tried to refresh, so a peer rotation that lands between the pre-check above and
|
|
5119
|
+
// this disable doesn't soft-delete the freshly-rotated row.
|
|
5120
|
+
const disabled =
|
|
5121
|
+
credentialId !== undefined
|
|
5122
|
+
? this.#disableCredentialByIdIfMatches(
|
|
5123
|
+
provider,
|
|
5124
|
+
credentialId,
|
|
5125
|
+
attemptedCredential,
|
|
5126
|
+
`oauth refresh failed: ${errorMsg}`,
|
|
5127
|
+
)
|
|
5128
|
+
: this.#tryDisableCredentialAtIfMatches(
|
|
5129
|
+
provider,
|
|
5130
|
+
index,
|
|
5131
|
+
attemptedCredential,
|
|
5132
|
+
`oauth refresh failed: ${errorMsg}`,
|
|
5133
|
+
);
|
|
5134
|
+
if (!disabled) {
|
|
5135
|
+
logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", { provider, index });
|
|
5136
|
+
await this.reload();
|
|
5137
|
+
return "cas-lost";
|
|
5138
|
+
}
|
|
5139
|
+
return "disabled";
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5004
5142
|
async #refreshOAuthCredential(
|
|
5005
5143
|
provider: Provider,
|
|
5006
5144
|
credential: OAuthCredential,
|
|
@@ -5048,8 +5186,31 @@ export class AuthStorage {
|
|
|
5048
5186
|
credentialId,
|
|
5049
5187
|
signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
|
|
5050
5188
|
),
|
|
5189
|
+
isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
|
|
5190
|
+
disabledCause: error => `oauth refresh failed: ${String(error)}`,
|
|
5051
5191
|
});
|
|
5052
|
-
if (result.credential)
|
|
5192
|
+
if (result.credential) {
|
|
5193
|
+
if (result.refreshed) {
|
|
5194
|
+
// We performed this refresh ourselves — trust the provider's new token
|
|
5195
|
+
// even when its lifetime is shorter than the refresh skew (some grants
|
|
5196
|
+
// are legitimately short-lived); the next resolve simply treats it as
|
|
5197
|
+
// due for refresh again instead of rejecting a token we just minted.
|
|
5198
|
+
if (Date.now() < result.credential.expires) return result.credential;
|
|
5199
|
+
} else if (Date.now() + OAUTH_REFRESH_SKEW_MS < result.credential.expires) {
|
|
5200
|
+
// Reloaded (not refreshed by us) credential — match #refreshOAuthCredential's
|
|
5201
|
+
// freshness contract: a reload within the refresh skew still counts as
|
|
5202
|
+
// needing refresh, so returning it here would make the final candidate pass
|
|
5203
|
+
// refresh the same row again and replay the token we just failed on.
|
|
5204
|
+
return result.credential;
|
|
5205
|
+
}
|
|
5206
|
+
throw new AIError.OAuthError(
|
|
5207
|
+
`OAuth refresh did not produce a usable credential for provider: ${provider}`,
|
|
5208
|
+
{
|
|
5209
|
+
kind: "token-refresh",
|
|
5210
|
+
provider,
|
|
5211
|
+
},
|
|
5212
|
+
);
|
|
5213
|
+
}
|
|
5053
5214
|
throw new AIError.OAuthError(`OAuth credential no longer exists for provider: ${provider}`, {
|
|
5054
5215
|
kind: "token-refresh",
|
|
5055
5216
|
provider,
|
|
@@ -5081,9 +5242,9 @@ export class AuthStorage {
|
|
|
5081
5242
|
provider,
|
|
5082
5243
|
});
|
|
5083
5244
|
}
|
|
5084
|
-
refreshPromise = customProvider.refreshToken(credential);
|
|
5245
|
+
refreshPromise = customProvider.refreshToken(credential, signal);
|
|
5085
5246
|
} else {
|
|
5086
|
-
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
|
|
5247
|
+
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential, signal);
|
|
5087
5248
|
}
|
|
5088
5249
|
}
|
|
5089
5250
|
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
|
|
@@ -5335,58 +5496,29 @@ export class AuthStorage {
|
|
|
5335
5496
|
});
|
|
5336
5497
|
|
|
5337
5498
|
if (isDefinitiveFailure) {
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
if (
|
|
5346
|
-
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
|
|
5347
|
-
const latestCredential = latestRow?.credential;
|
|
5348
|
-
if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
|
|
5349
|
-
logger.debug("OAuth refresh race detected; another process rotated token first", {
|
|
5350
|
-
provider,
|
|
5351
|
-
index: selection.index,
|
|
5352
|
-
credentialId,
|
|
5353
|
-
});
|
|
5354
|
-
await this.reload();
|
|
5355
|
-
if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
5356
|
-
}
|
|
5357
|
-
}
|
|
5358
|
-
// Permanently disable invalid credentials with an explicit cause for inspection/debugging.
|
|
5359
|
-
// Use a CAS-style disable conditioned on the row still containing the stale credential
|
|
5360
|
-
// we tried to refresh, so a peer rotation that lands between the pre-check above and
|
|
5361
|
-
// this disable doesn't soft-delete the freshly-rotated row.
|
|
5362
|
-
const disabled =
|
|
5363
|
-
credentialId !== undefined
|
|
5364
|
-
? this.#disableCredentialByIdIfMatches(
|
|
5365
|
-
provider,
|
|
5366
|
-
credentialId,
|
|
5367
|
-
selection.credential,
|
|
5368
|
-
`oauth refresh failed: ${errorMsg}`,
|
|
5369
|
-
)
|
|
5370
|
-
: this.#tryDisableCredentialAtIfMatches(
|
|
5371
|
-
provider,
|
|
5372
|
-
selection.index,
|
|
5373
|
-
selection.credential,
|
|
5374
|
-
`oauth refresh failed: ${errorMsg}`,
|
|
5375
|
-
);
|
|
5376
|
-
if (!disabled) {
|
|
5377
|
-
logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
|
|
5378
|
-
provider,
|
|
5379
|
-
index: selection.index,
|
|
5380
|
-
});
|
|
5381
|
-
await this.reload();
|
|
5499
|
+
const outcome = await this.#disableDefinitiveOAuthFailure(
|
|
5500
|
+
provider,
|
|
5501
|
+
credentialId,
|
|
5502
|
+
selection.credential,
|
|
5503
|
+
selection.index,
|
|
5504
|
+
errorMsg,
|
|
5505
|
+
);
|
|
5506
|
+
if (outcome === "peer-rotated") {
|
|
5382
5507
|
if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
5508
|
+
return undefined;
|
|
5383
5509
|
}
|
|
5510
|
+
if (outcome === "cas-lost") return undefined;
|
|
5384
5511
|
if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
|
|
5385
5512
|
if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
5386
5513
|
}
|
|
5387
5514
|
} else {
|
|
5388
5515
|
// Block temporarily for transient failures (5 minutes)
|
|
5389
|
-
this.#markCredentialBlocked(
|
|
5516
|
+
this.#markCredentialBlocked(
|
|
5517
|
+
provider,
|
|
5518
|
+
providerKey,
|
|
5519
|
+
selection.index,
|
|
5520
|
+
Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
|
|
5521
|
+
);
|
|
5390
5522
|
}
|
|
5391
5523
|
}
|
|
5392
5524
|
|
|
@@ -5891,6 +6023,28 @@ export class AuthStorage {
|
|
|
5891
6023
|
}
|
|
5892
6024
|
}
|
|
5893
6025
|
|
|
6026
|
+
/**
|
|
6027
|
+
* Expire cached reports for a provider after its runtime usage implementation changes.
|
|
6028
|
+
* This keeps a newly installed extension provider from serving a built-in snapshot.
|
|
6029
|
+
*/
|
|
6030
|
+
#invalidateUsageReportCacheForProvider(provider: Provider): void {
|
|
6031
|
+
this.#usageCacheEpoch += 1;
|
|
6032
|
+
const expired = Date.now() - 1;
|
|
6033
|
+
const prefix = `report:${this.#usageCacheProviderKey(provider)}:`;
|
|
6034
|
+
if (this.#usageCache.deletePrefix(prefix)) return;
|
|
6035
|
+
const cacheKeys = new Set(this.#usageReportCacheKeysByProvider.get(provider));
|
|
6036
|
+
for (const entry of this.#getStoredCredentials(provider)) {
|
|
6037
|
+
cacheKeys.add(
|
|
6038
|
+
this.#buildUsageReportCacheKey({
|
|
6039
|
+
provider,
|
|
6040
|
+
credential: this.#buildUsageCredential(entry.credential),
|
|
6041
|
+
}),
|
|
6042
|
+
);
|
|
6043
|
+
}
|
|
6044
|
+
for (const cacheKey of cacheKeys) {
|
|
6045
|
+
this.#usageCache.set(cacheKey, { value: null, expiresAt: expired });
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
5894
6048
|
/**
|
|
5895
6049
|
* Drop report snapshots for a user-requested refresh so a failed probe
|
|
5896
6050
|
* cannot replay the pre-invalidation last-good value. The persisted marker
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { InbandScanEvent, InbandScanner } from "./types";
|
|
2
|
+
|
|
3
|
+
const SECTION_OPEN = "<tool_calls>";
|
|
4
|
+
const SECTION_CLOSE = "</tool_calls>";
|
|
5
|
+
const TOOL_ELEMENT = /^<([A-Za-z_][\w.-]*)(\s[^<>]*?)?\s*\/>$/s;
|
|
6
|
+
const ATTRIBUTE = /([A-Za-z_][\w.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
7
|
+
|
|
8
|
+
export class QwenXmlInbandScanner implements InbandScanner {
|
|
9
|
+
#buffer = "";
|
|
10
|
+
#insideSection = false;
|
|
11
|
+
|
|
12
|
+
feed(chunk: string): InbandScanEvent[] {
|
|
13
|
+
this.#buffer += chunk;
|
|
14
|
+
return this.#drain(false);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
flush(): InbandScanEvent[] {
|
|
18
|
+
return this.#drain(true);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#drain(flush: boolean): InbandScanEvent[] {
|
|
22
|
+
const events: InbandScanEvent[] = [];
|
|
23
|
+
while (this.#buffer.length > 0) {
|
|
24
|
+
if (!this.#insideSection) {
|
|
25
|
+
const open = this.#buffer.indexOf(SECTION_OPEN);
|
|
26
|
+
if (open >= 0) {
|
|
27
|
+
if (open > 0) events.push({ type: "text", text: this.#buffer.slice(0, open) });
|
|
28
|
+
this.#buffer = this.#buffer.slice(open + SECTION_OPEN.length);
|
|
29
|
+
this.#insideSection = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (flush) {
|
|
33
|
+
events.push({ type: "text", text: this.#buffer });
|
|
34
|
+
this.#buffer = "";
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const held = longestSuffixPrefix(this.#buffer, SECTION_OPEN);
|
|
38
|
+
const visibleLength = this.#buffer.length - held;
|
|
39
|
+
if (visibleLength > 0) {
|
|
40
|
+
events.push({ type: "text", text: this.#buffer.slice(0, visibleLength) });
|
|
41
|
+
this.#buffer = this.#buffer.slice(visibleLength);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const close = this.#buffer.indexOf(SECTION_CLOSE);
|
|
47
|
+
if (close < 0) {
|
|
48
|
+
if (flush) this.#buffer = "";
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
const section = this.#buffer.slice(0, close);
|
|
52
|
+
for (const element of section.match(/<[^<>]+\/>/gs) ?? []) {
|
|
53
|
+
const call = parseToolElement(element);
|
|
54
|
+
if (!call) continue;
|
|
55
|
+
const id = `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
56
|
+
events.push({ type: "toolStart", id, name: call.name });
|
|
57
|
+
events.push({ type: "toolEnd", id, name: call.name, arguments: call.arguments, rawBlock: element });
|
|
58
|
+
}
|
|
59
|
+
this.#buffer = this.#buffer.slice(close + SECTION_CLOSE.length);
|
|
60
|
+
this.#insideSection = false;
|
|
61
|
+
}
|
|
62
|
+
return events;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseToolElement(element: string): { name: string; arguments: Record<string, unknown> } | undefined {
|
|
67
|
+
const match = TOOL_ELEMENT.exec(element);
|
|
68
|
+
if (!match) return undefined;
|
|
69
|
+
const args: Record<string, unknown> = {};
|
|
70
|
+
for (const attribute of match[2]?.matchAll(ATTRIBUTE) ?? []) {
|
|
71
|
+
args[attribute[1]!] = attribute[3] ?? attribute[4] ?? "";
|
|
72
|
+
}
|
|
73
|
+
return { name: match[1]!, arguments: args };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function longestSuffixPrefix(text: string, target: string): number {
|
|
77
|
+
const max = Math.min(text.length, target.length - 1);
|
|
78
|
+
for (let length = max; length > 0; length--) {
|
|
79
|
+
if (text.endsWith(target.slice(0, length))) return length;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import { isAccountPolicyError, isOAuthExpiry, isUsageLimit } from "./flags";
|
|
3
|
+
import { OAuthError } from "./oauth";
|
|
3
4
|
import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -25,16 +26,17 @@ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Whether an upstream failure should
|
|
29
|
-
* `401`, a `403` (token valid but access
|
|
30
|
-
* restriction a sibling account may not
|
|
31
|
-
* denial such as Codex `cyber_policy`, a
|
|
32
|
-
* `usage_limit_reached`, Anthropic account
|
|
33
|
-
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or
|
|
34
|
-
* whose payload did not preserve a richer quota code. Transient
|
|
35
|
-
* (`Too many requests`, per-minute caps) stay in the upstream-backoff lane.
|
|
29
|
+
* Whether an upstream failure should retry through the credential resolver: a
|
|
30
|
+
* typed token-refresh request, a hard `401`, a `403` (token valid but access
|
|
31
|
+
* denied — plan, model policy, or org restriction a sibling account may not
|
|
32
|
+
* share), an account-scoped policy denial such as Codex `cyber_policy`, a
|
|
33
|
+
* body-classified usage limit (Codex `usage_limit_reached`, Anthropic account
|
|
34
|
+
* rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`, …), or
|
|
35
|
+
* a bare `429` whose payload did not preserve a richer quota code. Transient
|
|
36
|
+
* 429s (`Too many requests`, per-minute caps) stay in the upstream-backoff lane.
|
|
36
37
|
*/
|
|
37
38
|
export function isAuthRetryableError(error: unknown): boolean {
|
|
39
|
+
if (error instanceof OAuthError && error.kind === "token-refresh") return true;
|
|
38
40
|
if (isUsageLimit(error)) return true;
|
|
39
41
|
if (isAccountPolicyError(error)) return true;
|
|
40
42
|
if (isInvalidatedOAuthTokenError(error)) return true;
|
package/src/error/oauth.ts
CHANGED
|
@@ -37,7 +37,8 @@ export interface OAuthErrorOptions {
|
|
|
37
37
|
/**
|
|
38
38
|
* A failure inside an interactive OAuth / device-code login flow. The `kind`
|
|
39
39
|
* pinpoints the stage. Timeout/polling are classified transient; everything
|
|
40
|
-
* else is a hard auth failure
|
|
40
|
+
* else is a hard auth failure. The auth-retry classifier separately treats
|
|
41
|
+
* `token-refresh` as an explicit request to refresh and replay once.
|
|
41
42
|
*/
|
|
42
43
|
export class OAuthError extends Error {
|
|
43
44
|
readonly kind: OAuthErrorKind;
|
|
@@ -779,17 +779,6 @@ function takeCachePoint(policy: BedrockPromptCachePolicy): CachePoint | undefine
|
|
|
779
779
|
return { cachePoint: { type: "default", ...(policy.ttl ? { ttl: policy.ttl } : {}) } };
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
-
/**
|
|
783
|
-
* Check if the model supports thinking signatures in reasoningContent.
|
|
784
|
-
* Only Anthropic Claude models support the signature field.
|
|
785
|
-
* Other models (Nova, Titan, Mistral, Llama, etc.) reject it with:
|
|
786
|
-
* "This model doesn't support the reasoningContent.reasoningText.signature field"
|
|
787
|
-
*/
|
|
788
|
-
function supportsThinkingSignature(model: Model<"bedrock-converse-stream">): boolean {
|
|
789
|
-
const id = model.id.toLowerCase();
|
|
790
|
-
return id.includes("anthropic.claude") || id.includes("anthropic/claude");
|
|
791
|
-
}
|
|
792
|
-
|
|
793
782
|
function buildSystemPrompt(
|
|
794
783
|
systemPrompt: readonly string[] | string | undefined,
|
|
795
784
|
promptCachePolicy: BedrockPromptCachePolicy,
|
|
@@ -869,21 +858,25 @@ function convertMessages(
|
|
|
869
858
|
case "thinking":
|
|
870
859
|
// Skip empty thinking blocks
|
|
871
860
|
if (c.thinking.trim().length === 0) continue;
|
|
872
|
-
// A captured signature is authoritative even when the model id is an opaque ARN
|
|
873
|
-
//
|
|
861
|
+
// A captured signature is authoritative even when the model id is an opaque ARN:
|
|
862
|
+
// only a model that itself streamed a signature (Claude) can have one, so replay
|
|
863
|
+
// it as signed reasoningContent regardless of how the id is spelled.
|
|
874
864
|
if (c.thinkingSignature) {
|
|
875
865
|
contentBlocks.push({
|
|
876
866
|
reasoningContent: {
|
|
877
867
|
reasoningText: { text: c.thinking.toWellFormed(), signature: c.thinkingSignature },
|
|
878
868
|
},
|
|
879
869
|
});
|
|
880
|
-
} else if (!supportsThinkingSignature(model)) {
|
|
881
|
-
// Model doesn't support signatures at all — send as unsigned reasoning
|
|
882
|
-
contentBlocks.push({
|
|
883
|
-
reasoningContent: { reasoningText: { text: c.thinking.toWellFormed() } },
|
|
884
|
-
});
|
|
885
870
|
} else {
|
|
886
|
-
//
|
|
871
|
+
// No signature was captured. Do NOT fall back to unsigned reasoningContent here:
|
|
872
|
+
// a model streaming reasoningContent does not imply it accepts reasoningContent
|
|
873
|
+
// echoed back in a request. Amazon Nova streams unsigned reasoning just fine but
|
|
874
|
+
// rejects it on replay with HTTP 400 "User messages cannot contain reasoning
|
|
875
|
+
// content. Please remove the reasoning content and try again.", which wedges the
|
|
876
|
+
// agent loop on every turn after the first. Demote to plain text instead — the
|
|
877
|
+
// content survives, just no longer typed as a reasoning block. This matches how
|
|
878
|
+
// every other provider (Anthropic, Google, OpenAI-completions) handles thinking
|
|
879
|
+
// blocks it can't safely replay.
|
|
887
880
|
contentBlocks.push({ text: renderDemotedThinking(model.id, c.thinking) });
|
|
888
881
|
}
|
|
889
882
|
break;
|