opencode-anthropic-fix 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +77 -24
- package/index.mjs +228 -41
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +241 -12
- package/lib/betas.mjs +1 -1
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +8 -1
- package/lib/mimicry/adapter-input.mjs +182 -18
- package/lib/mimicry/headers.mjs +1 -1
- package/lib/mimicry/response-stream.mjs +816 -112
- package/lib/mimicry/system-prompt.mjs +20 -10
- package/lib/mimicry/wire-compat.mjs +132 -15
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -91
- package/lib/rolling-summarizer.mjs +20 -5
- package/lib/token-economy/microcompact.mjs +3 -12
- package/lib/unicode-text.mjs +111 -0
- package/package.json +3 -1
package/index.mjs
CHANGED
|
@@ -3,7 +3,12 @@ import { stdin, stdout } from "node:process";
|
|
|
3
3
|
import { randomBytes, randomUUID, createHash as createHashCrypto } from "node:crypto";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, resolve, basename } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AccountManager,
|
|
8
|
+
RATE_LIMIT_KEY_FAST,
|
|
9
|
+
isBetaRetryAccountUsable,
|
|
10
|
+
warnOnEphemeralTokenRotation,
|
|
11
|
+
} from "./lib/accounts.mjs";
|
|
7
12
|
import {
|
|
8
13
|
authorize as oauthAuthorize,
|
|
9
14
|
exchange as oauthExchange,
|
|
@@ -32,11 +37,12 @@ import {
|
|
|
32
37
|
maybeApplySessionToolResultDedupe,
|
|
33
38
|
applyContextHintCompaction,
|
|
34
39
|
} from "./lib/token-economy/transforms.mjs";
|
|
35
|
-
import { shouldMicrocompact
|
|
40
|
+
import { shouldMicrocompact } from "./lib/token-economy/microcompact.mjs";
|
|
36
41
|
import { redactSecrets, redactString } from "./lib/redact.mjs";
|
|
37
42
|
import {
|
|
38
43
|
createTransformedSSEStream,
|
|
39
44
|
resolveStreamIdleTimeoutMs,
|
|
45
|
+
shouldApplyHostCompatShim,
|
|
40
46
|
stripMcpPrefixFromParsedEvent,
|
|
41
47
|
} from "./lib/mimicry/response-stream.mjs";
|
|
42
48
|
import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
|
|
@@ -44,7 +50,12 @@ import { isTruthyEnv } from "./lib/env.mjs";
|
|
|
44
50
|
import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
|
|
45
51
|
import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
|
|
46
52
|
import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
|
|
47
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
SERVICE_WIDE_MAX_RETRIES,
|
|
55
|
+
CONSECUTIVE_529_FALLBACK_THRESHOLD,
|
|
56
|
+
FOREGROUND_REFRESH_EXPIRY_BUFFER_MS,
|
|
57
|
+
} from "./lib/tuning.mjs";
|
|
58
|
+
import { isTokenExpired, isTokenUsable } from "./lib/account-state.mjs";
|
|
48
59
|
import {
|
|
49
60
|
computeServiceRetrySleepMs,
|
|
50
61
|
selectFallbackModel,
|
|
@@ -73,6 +84,7 @@ import {
|
|
|
73
84
|
assertAdapterBodyUsable,
|
|
74
85
|
buildAdapterTransport,
|
|
75
86
|
resolveAdapterEnv,
|
|
87
|
+
selectLatchableRejectedBetas,
|
|
76
88
|
} from "./lib/mimicry/adapter-input.mjs";
|
|
77
89
|
import { buildPassthroughHeaders, stripNonApiBodyFields } from "./lib/passthrough-headers.mjs";
|
|
78
90
|
import {
|
|
@@ -306,6 +318,21 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
306
318
|
const SESSION_REJECTED_BETA_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
307
319
|
/** @type {Map<string, number>} canonical-beta to rejected-at epoch ms */
|
|
308
320
|
const sessionRejectedBetas = new Map();
|
|
321
|
+
// Sent-beta suppression latch, keyed by ACCOUNT. Holds only betas that were on
|
|
322
|
+
// the failing request's header AND named by the API's invalid-beta message
|
|
323
|
+
// (selectLatchableRejectedBetas); they reach the adapter as `rejectedBetas` ->
|
|
324
|
+
// `suppressBetas` for SESSION_REJECTED_BETA_TTL_MS. Unlike
|
|
325
|
+
// `sessionRejectedBetas` (a custom_betas filter only), this one removes
|
|
326
|
+
// package-composed betas, so it must not leak to accounts that never
|
|
327
|
+
// received the rejection. Memory only.
|
|
328
|
+
/** @type {Map<string, Map<string, number>>} account key to (beta to rejected-at epoch ms) */
|
|
329
|
+
const sentBetaSuppressionsByAccount = new Map();
|
|
330
|
+
/**
|
|
331
|
+
* @param {{id?: unknown, index?: unknown} | null | undefined} acct
|
|
332
|
+
* @returns {string}
|
|
333
|
+
*/
|
|
334
|
+
const sentBetaLatchKey = (acct) =>
|
|
335
|
+
typeof acct?.id === "string" && acct.id ? acct.id : `index:${String(acct?.index ?? "")}`;
|
|
309
336
|
|
|
310
337
|
// Token economy — session state for layered compaction strategies.
|
|
311
338
|
const tokenEconomySession = {
|
|
@@ -1424,7 +1451,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
1424
1451
|
*/
|
|
1425
1452
|
async function getFilesAuth(acct) {
|
|
1426
1453
|
let tok = acct.access;
|
|
1427
|
-
if (!tok ||
|
|
1454
|
+
if (!tok || isTokenExpired(acct.expires)) {
|
|
1428
1455
|
tok = await refreshAccountTokenSingleFlight(acct);
|
|
1429
1456
|
}
|
|
1430
1457
|
return {
|
|
@@ -2284,7 +2311,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2284
2311
|
// Ignore idle failure here; foreground path handles refresh decisions.
|
|
2285
2312
|
}
|
|
2286
2313
|
|
|
2287
|
-
if (account.access && account.expires
|
|
2314
|
+
if (account.access && isTokenUsable(account.expires)) {
|
|
2288
2315
|
return account.access;
|
|
2289
2316
|
}
|
|
2290
2317
|
} else {
|
|
@@ -2397,7 +2424,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2397
2424
|
const excluded = new Set([activeAccount.index]);
|
|
2398
2425
|
const candidates = accountManager
|
|
2399
2426
|
.getEnabledAccounts(excluded)
|
|
2400
|
-
.filter((acc) =>
|
|
2427
|
+
.filter((acc) => isTokenExpired(acc.expires, getIdleRefreshWindowMs(), now))
|
|
2401
2428
|
.filter((acc) => {
|
|
2402
2429
|
const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
|
|
2403
2430
|
return now - last >= getIdleRefreshMinIntervalMs();
|
|
@@ -2587,6 +2614,18 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2587
2614
|
const requestMethod = String(
|
|
2588
2615
|
requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
|
|
2589
2616
|
).toUpperCase();
|
|
2617
|
+
// The HOST's own User-Agent (not the one sent to Anthropic): it
|
|
2618
|
+
// names the host's @ai-sdk/anthropic version, which decides
|
|
2619
|
+
// whether the L5 host-compat stream shim must run.
|
|
2620
|
+
const hostCompatShim = shouldApplyHostCompatShim(getIncomingHeader(input, requestInit, "user-agent"));
|
|
2621
|
+
/** @param {{blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}} shimStats */
|
|
2622
|
+
const onHostCompatShim = (shimStats) =>
|
|
2623
|
+
shimStats.dryRun
|
|
2624
|
+
? debugLog(
|
|
2625
|
+
"host-compat shim disabled for this host SDK version, but would have rewritten the response stream (dry run) — allowlists may need re-extracting",
|
|
2626
|
+
shimStats,
|
|
2627
|
+
)
|
|
2628
|
+
: debugLog("host-compat shim rewrote the response stream", shimStats);
|
|
2590
2629
|
let showUsageToast;
|
|
2591
2630
|
try {
|
|
2592
2631
|
showUsageToast = new URL(requestUrl).pathname === "/v1/messages" && requestMethod === "POST";
|
|
@@ -2694,10 +2733,25 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2694
2733
|
let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
|
|
2695
2734
|
let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
|
|
2696
2735
|
let customBetasStripped = false; // One-shot latch: strip config.custom_betas once per logical request
|
|
2736
|
+
let sentBetaRejectionRetried = false; // One-shot latch: retry once after latching a rejected SENT beta
|
|
2737
|
+
/** @type {{key: string, betas: string[], recordedAt: number} | null} */
|
|
2738
|
+
let pendingSentBetaLatch = null; // Entries latched for the retry in flight; dropped if it fails the same way
|
|
2739
|
+
// One-shot: the account that just received a beta rejection. The
|
|
2740
|
+
// beta-rejection retry (`attempt--; continue`) must land on it: the
|
|
2741
|
+
// sent-beta latch is keyed per account, so a rotating strategy
|
|
2742
|
+
// (round-robin, hybrid) moving the retry to another account would
|
|
2743
|
+
// resend the rejected beta and burn the single retry.
|
|
2744
|
+
let betaRetryAccount = null;
|
|
2697
2745
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2698
|
-
// Select account —
|
|
2699
|
-
|
|
2700
|
-
|
|
2746
|
+
// Select account — a beta-rejection retry stays on the rejecting
|
|
2747
|
+
// account (QA fix L5: only if it's still enabled and still
|
|
2748
|
+
// tracked by the account manager — see isBetaRetryAccountUsable);
|
|
2749
|
+
// otherwise use the pinned account on the first attempt.
|
|
2750
|
+
const _betaRetryAccount = betaRetryAccount;
|
|
2751
|
+
betaRetryAccount = null;
|
|
2752
|
+
const account = isBetaRetryAccountUsable(_betaRetryAccount, accountManager, transientRefreshSkips)
|
|
2753
|
+
? _betaRetryAccount
|
|
2754
|
+
: attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
|
|
2701
2755
|
? pinnedAccount
|
|
2702
2756
|
: accountManager.getCurrentAccount(transientRefreshSkips);
|
|
2703
2757
|
|
|
@@ -2738,7 +2792,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2738
2792
|
let accessToken;
|
|
2739
2793
|
// Per-account token refresh
|
|
2740
2794
|
// Refresh 5 minutes before expiry to avoid mid-request token expiration (RE doc §1.10)
|
|
2741
|
-
if (!account.access ||
|
|
2795
|
+
if (!account.access || isTokenExpired(account.expires, FOREGROUND_REFRESH_EXPIRY_BUFFER_MS)) {
|
|
2742
2796
|
const attemptedRefreshToken = account.refreshToken;
|
|
2743
2797
|
try {
|
|
2744
2798
|
accessToken = await refreshAccountTokenSingleFlight(account);
|
|
@@ -2898,14 +2952,12 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2898
2952
|
const _baseTE = config.token_economy || {};
|
|
2899
2953
|
const _tokenEconomy = { ..._baseTE, __requestRole: _requestRole };
|
|
2900
2954
|
|
|
2901
|
-
// Microcompact:
|
|
2902
|
-
let _microcompactBetas = null;
|
|
2955
|
+
// Microcompact: track high context utilization (state + toast only)
|
|
2903
2956
|
if (requestInit.body) {
|
|
2904
2957
|
const estimatedTokens = _parsedBodyOnce
|
|
2905
2958
|
? estimatePromptTokensFromParsed(_parsedBodyOnce)
|
|
2906
2959
|
: estimatePromptTokens(requestInit.body);
|
|
2907
2960
|
if (shouldMicrocompact(estimatedTokens, config)) {
|
|
2908
|
-
_microcompactBetas = buildMicrocompactBetas();
|
|
2909
2961
|
if (!microcompactState.active) {
|
|
2910
2962
|
microcompactState.active = true;
|
|
2911
2963
|
microcompactState.lastActivatedTurn = sessionMetrics.turns;
|
|
@@ -2934,6 +2986,29 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2934
2986
|
}
|
|
2935
2987
|
return false;
|
|
2936
2988
|
});
|
|
2989
|
+
// Evicting a rejected beta from `customBetas` is not enough on the
|
|
2990
|
+
// adapter path: the package composes some betas on its own (e.g.
|
|
2991
|
+
// `cache-diagnosis-2026-04-07` under 2.1.280), and only
|
|
2992
|
+
// `suppressBetas` (its terminal filter) can take those off the
|
|
2993
|
+
// wire. But suppression also removes the package's OWN copy of a
|
|
2994
|
+
// beta (e.g. context-1m under adaptive 1M), so it is reserved for
|
|
2995
|
+
// betas the API explicitly named as invalid for THIS account: the
|
|
2996
|
+
// unexpired entries of `sentBetaSuppressionsByAccount`. The
|
|
2997
|
+
// `customBetasStripped` one-shot and `sessionRejectedBetas` only
|
|
2998
|
+
// filter `customBetas` above, exactly as before 2.1.280. The
|
|
2999
|
+
// adapter never suppresses UNSUPPRESSIBLE_BETAS or
|
|
3000
|
+
// BODY_COUPLED_BETAS from this list.
|
|
3001
|
+
const _sessionRejectedBetaNames = new Set();
|
|
3002
|
+
const _accountSentBetaLatch = sentBetaSuppressionsByAccount.get(sentBetaLatchKey(account));
|
|
3003
|
+
if (_accountSentBetaLatch) {
|
|
3004
|
+
for (const [beta, rejectedAt] of _accountSentBetaLatch) {
|
|
3005
|
+
if (Date.now() - rejectedAt > SESSION_REJECTED_BETA_TTL_MS) {
|
|
3006
|
+
_accountSentBetaLatch.delete(beta);
|
|
3007
|
+
} else {
|
|
3008
|
+
_sessionRejectedBetaNames.add(beta);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
2937
3012
|
// PHASE 2.2.3 — THE BETA LATCH IS GONE. It used to merge every
|
|
2938
3013
|
// beta ever sent this session back into this value, to avoid
|
|
2939
3014
|
// server-side cache-key churn from a beta flipping mid-session.
|
|
@@ -3152,6 +3227,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
3152
3227
|
enabled: getSignatureEmulationEnabled(),
|
|
3153
3228
|
claudeCliVersion,
|
|
3154
3229
|
customBetas: _sessionFilteredCustomBetas,
|
|
3230
|
+
rejectedBetas: [..._sessionRejectedBetaNames],
|
|
3155
3231
|
strategy: getEffectiveStrategy(),
|
|
3156
3232
|
sessionId: signatureSessionId,
|
|
3157
3233
|
};
|
|
@@ -3702,43 +3778,121 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
3702
3778
|
// context_window, long context, 1m/million context) to avoid false positives
|
|
3703
3779
|
// on generic 413s (e.g. plain upload-size limits with an empty body).
|
|
3704
3780
|
// One retry per logical request (latch prevents loop).
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
(config.custom_betas?.length ?? 0) > 0 &&
|
|
3708
|
-
((response.status === 400 &&
|
|
3781
|
+
const _betaRejectionSignal =
|
|
3782
|
+
(response.status === 400 &&
|
|
3709
3783
|
errorBody &&
|
|
3710
3784
|
errorBody.includes("anthropic-beta") &&
|
|
3711
3785
|
!errorBody.includes("context-hint")) ||
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3786
|
+
(response.status === 413 &&
|
|
3787
|
+
errorBody &&
|
|
3788
|
+
/anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
|
|
3789
|
+
errorBody,
|
|
3790
|
+
));
|
|
3791
|
+
// The retry that followed a sent-beta latch failed the same way on
|
|
3792
|
+
// the SAME account (so the suppression was actually applied): it
|
|
3793
|
+
// did not help, so drop the entries just latched instead of
|
|
3794
|
+
// carrying a useless 5-minute suppression.
|
|
3795
|
+
const _latchUnderTest = pendingSentBetaLatch;
|
|
3796
|
+
pendingSentBetaLatch = null;
|
|
3797
|
+
if (_betaRejectionSignal && _latchUnderTest && _latchUnderTest.key === sentBetaLatchKey(account)) {
|
|
3798
|
+
const _latched = sentBetaSuppressionsByAccount.get(_latchUnderTest.key);
|
|
3799
|
+
for (const _beta of _latchUnderTest.betas) {
|
|
3800
|
+
if (_latched?.get(_beta) === _latchUnderTest.recordedAt) _latched.delete(_beta);
|
|
3801
|
+
}
|
|
3802
|
+
debugLog("sent beta latch did not clear the rejection - dropped", _latchUnderTest.betas);
|
|
3803
|
+
}
|
|
3804
|
+
// Betas the API's invalid-beta message ("Unexpected value(s) `X`
|
|
3805
|
+
// for the `anthropic-beta` header") names that were actually on
|
|
3806
|
+
// the failing request's header. Under 2.1.280 the package
|
|
3807
|
+
// composes some betas by default (e.g. cache-diagnosis), so a
|
|
3808
|
+
// rejection can name a beta the user never configured; only the
|
|
3809
|
+
// adapter path can suppress those (via `rejectedBetas` ->
|
|
3810
|
+
// `suppressBetas`). `_sentLatchableBetas` excludes OAuth / Claude
|
|
3811
|
+
// Code (UNSUPPRESSIBLE_BETAS) and BODY_COUPLED_BETAS, and both
|
|
3812
|
+
// lists are empty when the error echoes the header (too many
|
|
3813
|
+
// matches). count_tokens is excluded: its input has no
|
|
3814
|
+
// suppressBetas, so a latch would not change its retry and would
|
|
3815
|
+
// only degrade the following /v1/messages requests.
|
|
3816
|
+
let _namedSentBetas = [];
|
|
3817
|
+
let _sentLatchableBetas = [];
|
|
3818
|
+
if (_betaRejectionSignal && _useAdapter && !_isCountTokens) {
|
|
3819
|
+
const _sentHeader =
|
|
3820
|
+
typeof requestHeaders?.get === "function"
|
|
3821
|
+
? requestHeaders.get("anthropic-beta")
|
|
3822
|
+
: requestHeaders?.["anthropic-beta"];
|
|
3823
|
+
({ namedSent: _namedSentBetas, latchable: _sentLatchableBetas } = selectLatchableRejectedBetas(
|
|
3824
|
+
_sentHeader,
|
|
3825
|
+
errorBody,
|
|
3826
|
+
));
|
|
3827
|
+
}
|
|
3828
|
+
/**
|
|
3829
|
+
* Latch `_sentLatchableBetas` for the account that received the
|
|
3830
|
+
* rejection, and remember them so a failed retry can undo it.
|
|
3831
|
+
* @param {number} recordedAt
|
|
3832
|
+
*/
|
|
3833
|
+
const _latchSentBetas = (recordedAt) => {
|
|
3834
|
+
const _key = sentBetaLatchKey(account);
|
|
3835
|
+
let _latched = sentBetaSuppressionsByAccount.get(_key);
|
|
3836
|
+
if (!_latched) {
|
|
3837
|
+
_latched = new Map();
|
|
3838
|
+
sentBetaSuppressionsByAccount.set(_key, _latched);
|
|
3839
|
+
}
|
|
3840
|
+
for (const _beta of _sentLatchableBetas) _latched.set(_beta, recordedAt);
|
|
3841
|
+
pendingSentBetaLatch = { key: _key, betas: [..._sentLatchableBetas], recordedAt };
|
|
3842
|
+
sentBetaRejectionRetried = true;
|
|
3843
|
+
};
|
|
3844
|
+
if (!customBetasStripped && (config.custom_betas?.length ?? 0) > 0 && _betaRejectionSignal) {
|
|
3718
3845
|
customBetasStripped = true;
|
|
3719
3846
|
// F4: record rejection in session latch so next logical request already
|
|
3720
|
-
// omits the rejected beta without needing a first-fail.
|
|
3721
|
-
//
|
|
3722
|
-
//
|
|
3847
|
+
// omits the rejected beta from `customBetas` without needing a first-fail.
|
|
3848
|
+
// When the invalid-beta message names betas that were sent, record only
|
|
3849
|
+
// the custom betas among them. Otherwise (no parsable names) fall back to
|
|
3850
|
+
// custom betas mentioned in the body, then to all custom betas. This latch
|
|
3851
|
+
// only filters `customBetas`; it never suppresses a package-composed beta.
|
|
3723
3852
|
{
|
|
3724
3853
|
const _allCustom = config.custom_betas ?? [];
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
const
|
|
3854
|
+
let _toRecord;
|
|
3855
|
+
if (_namedSentBetas.length > 0) {
|
|
3856
|
+
const _namedLc = new Set(_namedSentBetas.map((_b) => _b.toLowerCase()));
|
|
3857
|
+
_toRecord = _allCustom.filter((_sb) => _namedLc.has(resolveBetaShortcut(_sb).toLowerCase()));
|
|
3858
|
+
} else {
|
|
3728
3859
|
const _bodyLc = (errorBody || "").toLowerCase();
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3860
|
+
const _mentioned = _allCustom.filter((_sb) => {
|
|
3861
|
+
const _rawLc = _sb.toLowerCase();
|
|
3862
|
+
const _canLc = resolveBetaShortcut(_sb).toLowerCase();
|
|
3863
|
+
return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
|
|
3864
|
+
});
|
|
3865
|
+
_toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
|
|
3866
|
+
}
|
|
3732
3867
|
const _recordedAt = Date.now();
|
|
3733
3868
|
for (const _sb of _toRecord) {
|
|
3734
3869
|
sessionRejectedBetas.set(resolveBetaShortcut(_sb), _recordedAt);
|
|
3735
3870
|
}
|
|
3871
|
+
// Same single retry also suppresses any named SENT beta, so it does
|
|
3872
|
+
// not take a second retry to clear a package-composed one.
|
|
3873
|
+
if (_sentLatchableBetas.length > 0) _latchSentBetas(_recordedAt);
|
|
3736
3874
|
}
|
|
3737
3875
|
attempt--;
|
|
3876
|
+
betaRetryAccount = account;
|
|
3738
3877
|
debugLog("custom beta/context rejection - retrying without custom betas");
|
|
3739
3878
|
continue;
|
|
3740
3879
|
}
|
|
3741
3880
|
|
|
3881
|
+
// Sent-beta rejection retry: the error names a beta that was on the
|
|
3882
|
+
// wire but is not (only) a custom beta, e.g. the package-default
|
|
3883
|
+
// cache-diagnosis with empty custom_betas. Latch it for this account
|
|
3884
|
+
// (TTL'd, as above) and retry once; `sentBetaRejectionRetried`
|
|
3885
|
+
// prevents a loop. Like the custom-beta path, this runs before any
|
|
3886
|
+
// account-failure bookkeeping and on a non-ok response, so no stream
|
|
3887
|
+
// was delivered.
|
|
3888
|
+
if (!sentBetaRejectionRetried && _sentLatchableBetas.length > 0) {
|
|
3889
|
+
_latchSentBetas(Date.now());
|
|
3890
|
+
attempt--;
|
|
3891
|
+
betaRetryAccount = account;
|
|
3892
|
+
debugLog("sent beta rejection - retrying without", _sentLatchableBetas);
|
|
3893
|
+
continue;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3742
3896
|
// Reactive compaction: on "prompt too long" error, trim oldest messages and retry once
|
|
3743
3897
|
if (
|
|
3744
3898
|
response.status === 400 &&
|
|
@@ -3906,7 +4060,10 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
3906
4060
|
status: response.status,
|
|
3907
4061
|
errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
|
|
3908
4062
|
});
|
|
3909
|
-
return transformResponse(response, undefined, undefined, correlationId
|
|
4063
|
+
return transformResponse(response, undefined, undefined, correlationId, {
|
|
4064
|
+
hostCompatShim,
|
|
4065
|
+
onHostCompatShim,
|
|
4066
|
+
});
|
|
3910
4067
|
}
|
|
3911
4068
|
|
|
3912
4069
|
const accountSpecific = isAccountSpecificError(response.status, errorBody);
|
|
@@ -4132,7 +4289,10 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
4132
4289
|
status: response.status,
|
|
4133
4290
|
});
|
|
4134
4291
|
}
|
|
4135
|
-
return transformResponse(response, undefined, undefined, correlationId
|
|
4292
|
+
return transformResponse(response, undefined, undefined, correlationId, {
|
|
4293
|
+
hostCompatShim,
|
|
4294
|
+
onHostCompatShim,
|
|
4295
|
+
});
|
|
4136
4296
|
}
|
|
4137
4297
|
|
|
4138
4298
|
// Success
|
|
@@ -4328,6 +4488,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
4328
4488
|
: null,
|
|
4329
4489
|
accountErrorCallback,
|
|
4330
4490
|
correlationId,
|
|
4491
|
+
{ hostCompatShim, onHostCompatShim },
|
|
4331
4492
|
);
|
|
4332
4493
|
}
|
|
4333
4494
|
|
|
@@ -4527,7 +4688,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
4527
4688
|
|
|
4528
4689
|
const getAccessToken = async () => {
|
|
4529
4690
|
let tok = account.access;
|
|
4530
|
-
if (!tok ||
|
|
4691
|
+
if (!tok || isTokenExpired(account.expires)) {
|
|
4531
4692
|
tok = await refreshAccountTokenSingleFlight(account);
|
|
4532
4693
|
}
|
|
4533
4694
|
if (!tok) throw new Error("no access token available for Haiku call");
|
|
@@ -6083,15 +6244,27 @@ function transformRequestUrl(input, emulateSignature = true) {
|
|
|
6083
6244
|
* @param {((details: {reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [onAccountError]
|
|
6084
6245
|
* Called if a mid-stream error looks account-specific
|
|
6085
6246
|
* @param {string} [correlationId]
|
|
6247
|
+
* @param {object} [hostCompat] - L5 host-compat shim wiring.
|
|
6248
|
+
* @param {boolean} [hostCompat.hostCompatShim] - Run the shim (see
|
|
6249
|
+
* `shouldApplyHostCompatShim`); defaults to on.
|
|
6250
|
+
* @param {((stats: {blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}) => void) | null} [hostCompat.onHostCompatShim]
|
|
6086
6251
|
* @returns {Response}
|
|
6087
6252
|
*/
|
|
6088
|
-
function transformResponse(
|
|
6253
|
+
function transformResponse(
|
|
6254
|
+
response,
|
|
6255
|
+
onUsage,
|
|
6256
|
+
onAccountError,
|
|
6257
|
+
correlationId,
|
|
6258
|
+
{ hostCompatShim = true, onHostCompatShim = null } = {},
|
|
6259
|
+
) {
|
|
6089
6260
|
if (!response.body) return response;
|
|
6090
6261
|
const idleTimeoutMs = resolveStreamIdleTimeoutMs(_pluginConfig);
|
|
6091
6262
|
const captureEnabled = _pluginConfig?.token_economy?.debug_dump_bodies === true && Boolean(correlationId);
|
|
6092
6263
|
const stream = createTransformedSSEStream(response, {
|
|
6093
6264
|
onUsage,
|
|
6094
6265
|
onAccountError,
|
|
6266
|
+
hostCompatShim,
|
|
6267
|
+
onHostCompatShim,
|
|
6095
6268
|
correlationId,
|
|
6096
6269
|
idleTimeoutMs,
|
|
6097
6270
|
captureEnabled,
|
|
@@ -6100,6 +6273,13 @@ function transformResponse(response, onUsage, onAccountError, correlationId) {
|
|
|
6100
6273
|
|
|
6101
6274
|
// Inject cache transparency headers (session-level, available before stream completes).
|
|
6102
6275
|
const responseHeaders = new Headers(response.headers);
|
|
6276
|
+
// QA fix L3: undici already decoded `response.body` (e.g. gzip was
|
|
6277
|
+
// transparently inflated) before it ever reached createTransformedSSEStream,
|
|
6278
|
+
// so a Content-Length/Content-Encoding copied from the upstream response
|
|
6279
|
+
// describes the ORIGINAL (possibly compressed) wire bytes, not the stream
|
|
6280
|
+
// this Response actually serves — forwarding either would lie to the host.
|
|
6281
|
+
responseHeaders.delete("content-length");
|
|
6282
|
+
responseHeaders.delete("content-encoding");
|
|
6103
6283
|
responseHeaders.set("x-opencode-cache-hit-rate", String(Math.round(getAverageCacheHitRate() * 1000) / 1000));
|
|
6104
6284
|
responseHeaders.set("x-opencode-cache-read-total", String(sessionMetrics.totalCacheRead));
|
|
6105
6285
|
responseHeaders.set("x-opencode-session-cost", String(Math.round(sessionMetrics.sessionCostUsd * 10000) / 10000));
|
|
@@ -6170,7 +6350,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
|
|
|
6170
6350
|
const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
|
|
6171
6351
|
const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
|
|
6172
6352
|
const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
|
|
6173
|
-
const memAuthExpired =
|
|
6353
|
+
const memAuthExpired = isTokenExpired(account.expires);
|
|
6174
6354
|
const allowExpiredFallback = options.allowExpiredFallback === true;
|
|
6175
6355
|
if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
|
|
6176
6356
|
return false;
|
|
@@ -6206,7 +6386,7 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
|
|
|
6206
6386
|
// Accept CC credential if:
|
|
6207
6387
|
// - expiresAt is in the future (normal case), OR
|
|
6208
6388
|
// - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
|
|
6209
|
-
if (match && (match.expiresAt === 0 || match.expiresAt
|
|
6389
|
+
if (match && (match.expiresAt === 0 || isTokenUsable(match.expiresAt))) {
|
|
6210
6390
|
account.access = match.accessToken;
|
|
6211
6391
|
account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
|
|
6212
6392
|
markTokenStateUpdated(account);
|
|
@@ -6242,7 +6422,7 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
|
|
|
6242
6422
|
if (!lock.acquired) {
|
|
6243
6423
|
const diskAuth = await readDiskAccountAuth(account.id);
|
|
6244
6424
|
const adopted = applyDiskAuthIfFresher(account, diskAuth, { allowExpiredFallback: true });
|
|
6245
|
-
if (adopted && account.access && account.expires
|
|
6425
|
+
if (adopted && account.access && isTokenUsable(account.expires)) {
|
|
6246
6426
|
return account.access;
|
|
6247
6427
|
}
|
|
6248
6428
|
throw new Error("Refresh lock busy");
|
|
@@ -6253,14 +6433,21 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
|
|
|
6253
6433
|
const adopted = applyDiskAuthIfFresher(account, diskAuthBeforeRefresh);
|
|
6254
6434
|
// Apply fresher disk tokens for both foreground and idle paths — prevents an
|
|
6255
6435
|
// unnecessary HTTP refresh when another process already rotated the token.
|
|
6256
|
-
if (adopted && account.access && account.expires
|
|
6436
|
+
if (adopted && account.access && isTokenUsable(account.expires)) {
|
|
6257
6437
|
return account.access;
|
|
6258
6438
|
}
|
|
6259
6439
|
|
|
6260
|
-
|
|
6440
|
+
// `clientId` is undefined for every normal account, which means "use the
|
|
6441
|
+
// default". Only a CLAUDE_CODE_OAUTH_CLIENT_ID-seeded env account carries one
|
|
6442
|
+
// (contract §5.2, divergence D10).
|
|
6443
|
+
const json = await refreshToken(account.refreshToken, {
|
|
6444
|
+
signal: AbortSignal.timeout(15_000),
|
|
6445
|
+
clientId: account.clientId,
|
|
6446
|
+
});
|
|
6261
6447
|
|
|
6262
6448
|
account.access = json.access_token;
|
|
6263
6449
|
account.expires = Date.now() + json.expires_in * 1000;
|
|
6450
|
+
warnOnEphemeralTokenRotation(account, json.refresh_token);
|
|
6264
6451
|
if (json.refresh_token) {
|
|
6265
6452
|
account.refreshToken = json.refresh_token;
|
|
6266
6453
|
}
|
package/lib/account-state.mjs
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
import { OAUTH_EXPIRY_SKEW_SECONDS } from "./oauth-constants.mjs";
|
|
2
|
+
|
|
3
|
+
/** The attested 30-second expiry skew, in milliseconds. @see docs/oauth-2.1.280-contract.md §3 */
|
|
4
|
+
export const TOKEN_EXPIRY_SKEW_MS = OAUTH_EXPIRY_SKEW_SECONDS * 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The single expiry predicate. Every "is this access token still usable?"
|
|
8
|
+
* question in this repository routes through here.
|
|
9
|
+
*
|
|
10
|
+
* ATTESTED: §13.8 (byte 4418342) records `expiry skew (seconds): 30`. A token is
|
|
11
|
+
* therefore treated as expired 30 s before its nominal expiry, so a request is
|
|
12
|
+
* never sent with a credential that dies in flight.
|
|
13
|
+
*
|
|
14
|
+
* WHY leadMs IS A FLOOR AND NOT AN ADDEND. Some callers deliberately refresh far
|
|
15
|
+
* ahead of expiry — a foreground buffer, an idle-refresh window. Adding the skew
|
|
16
|
+
* to those would silently retune them; dropping the skew for them would leave a
|
|
17
|
+
* site that can still call a token valid seconds before it dies. Taking the
|
|
18
|
+
* larger of the two keeps every tuned lead time exactly as it was while
|
|
19
|
+
* guaranteeing the 30 s floor everywhere.
|
|
20
|
+
*
|
|
21
|
+
* A missing or non-numeric `expires` is EXPIRED, not valid: an unknown expiry is
|
|
22
|
+
* not evidence of freshness, and the alternative is sending a dead token.
|
|
23
|
+
*
|
|
24
|
+
* @param {number | null | undefined} expires - Absolute expiry, ms since epoch
|
|
25
|
+
* @param {number} [leadMs=0] - Extra lead time this caller wants
|
|
26
|
+
* @param {number} [now=Date.now()] - Injectable clock for tests
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
* @see docs/oauth-2.1.280-contract.md §3
|
|
29
|
+
*/
|
|
30
|
+
export function isTokenExpired(expires, leadMs = 0, now = Date.now()) {
|
|
31
|
+
if (typeof expires !== "number" || !Number.isFinite(expires)) return true;
|
|
32
|
+
const lead = Math.max(Number.isFinite(leadMs) ? leadMs : 0, TOKEN_EXPIRY_SKEW_MS);
|
|
33
|
+
return now >= expires - lead;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convenience inverse of {@link isTokenExpired}. Exists so call sites read as
|
|
38
|
+
* the question they are asking instead of as a negation.
|
|
39
|
+
*
|
|
40
|
+
* @param {number | null | undefined} expires
|
|
41
|
+
* @param {number} [leadMs=0]
|
|
42
|
+
* @param {number} [now=Date.now()]
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isTokenUsable(expires, leadMs = 0, now = Date.now()) {
|
|
46
|
+
return !isTokenExpired(expires, leadMs, now);
|
|
47
|
+
}
|
|
48
|
+
|
|
1
49
|
/**
|
|
2
50
|
* @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
|
|
3
51
|
* @typedef {import('./storage.mjs').AccountStorage} AccountStorage
|