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/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 { AccountManager, RATE_LIMIT_KEY_FAST } from "./lib/accounts.mjs";
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, buildMicrocompactBetas } from "./lib/token-economy/microcompact.mjs";
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 { SERVICE_WIDE_MAX_RETRIES, CONSECUTIVE_529_FALLBACK_THRESHOLD } from "./lib/tuning.mjs";
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 || !acct.expires || acct.expires < Date.now()) {
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 && account.expires > Date.now()) {
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) => !acc.expires || acc.expires <= now + getIdleRefreshWindowMs())
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 — use pinned account on first attempt if available
2699
- const account =
2700
- attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
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 || !account.expires || account.expires < Date.now() + 300_000) {
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: inject clear betas at high context utilization
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
- if (
3706
- !customBetasStripped &&
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
- (response.status === 413 &&
3713
- errorBody &&
3714
- /anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
3715
- errorBody,
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
- // Only record betas explicitly mentioned in the error body (raw or canonical);
3722
- // fall back to recording all custom betas if the body names none specifically.
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
- const _mentioned = _allCustom.filter((_sb) => {
3726
- const _rawLc = _sb.toLowerCase();
3727
- const _canLc = resolveBetaShortcut(_sb).toLowerCase();
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
- return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
3730
- });
3731
- const _toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
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 || !account.expires || account.expires < Date.now()) {
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(response, onUsage, onAccountError, correlationId) {
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 = !account.expires || account.expires <= Date.now();
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 > Date.now())) {
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 && account.expires > Date.now()) {
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 && account.expires > Date.now()) {
6436
+ if (adopted && account.access && isTokenUsable(account.expires)) {
6257
6437
  return account.access;
6258
6438
  }
6259
6439
 
6260
- const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(15_000) });
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
  }
@@ -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