opencode-anthropic-fix 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, warnOnEphemeralTokenRotation } 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,
@@ -37,6 +42,7 @@ 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";
@@ -78,6 +84,7 @@ import {
78
84
  assertAdapterBodyUsable,
79
85
  buildAdapterTransport,
80
86
  resolveAdapterEnv,
87
+ selectLatchableRejectedBetas,
81
88
  } from "./lib/mimicry/adapter-input.mjs";
82
89
  import { buildPassthroughHeaders, stripNonApiBodyFields } from "./lib/passthrough-headers.mjs";
83
90
  import {
@@ -90,8 +97,13 @@ import {
90
97
  isOpus47Model,
91
98
  isOpus48Model,
92
99
  } from "./lib/mimicry/wire-compat.mjs";
93
-
94
- export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/wire-compat.mjs";
100
+ import {
101
+ createDebugCorrelationId,
102
+ isDebugSinkEnabled,
103
+ createDebugRequestDump,
104
+ createDebugOutgoingHeadersEntry,
105
+ createDebugResponseHeadersEntry,
106
+ } from "./lib/debug-dump.mjs";
95
107
 
96
108
  // Max times a single logical request may fall back from fast->standard speed on
97
109
  // the same account before giving up the fast attempt entirely. 1 is enough: one
@@ -311,6 +323,21 @@ export async function AnthropicAuthPlugin({ client }) {
311
323
  const SESSION_REJECTED_BETA_TTL_MS = 5 * 60 * 1000; // 5 minutes
312
324
  /** @type {Map<string, number>} canonical-beta to rejected-at epoch ms */
313
325
  const sessionRejectedBetas = new Map();
326
+ // Sent-beta suppression latch, keyed by ACCOUNT. Holds only betas that were on
327
+ // the failing request's header AND named by the API's invalid-beta message
328
+ // (selectLatchableRejectedBetas); they reach the adapter as `rejectedBetas` ->
329
+ // `suppressBetas` for SESSION_REJECTED_BETA_TTL_MS. Unlike
330
+ // `sessionRejectedBetas` (a custom_betas filter only), this one removes
331
+ // package-composed betas, so it must not leak to accounts that never
332
+ // received the rejection. Memory only.
333
+ /** @type {Map<string, Map<string, number>>} account key to (beta to rejected-at epoch ms) */
334
+ const sentBetaSuppressionsByAccount = new Map();
335
+ /**
336
+ * @param {{id?: unknown, index?: unknown} | null | undefined} acct
337
+ * @returns {string}
338
+ */
339
+ const sentBetaLatchKey = (acct) =>
340
+ typeof acct?.id === "string" && acct.id ? acct.id : `index:${String(acct?.index ?? "")}`;
314
341
 
315
342
  // Token economy — session state for layered compaction strategies.
316
343
  const tokenEconomySession = {
@@ -2592,6 +2619,18 @@ export async function AnthropicAuthPlugin({ client }) {
2592
2619
  const requestMethod = String(
2593
2620
  requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
2594
2621
  ).toUpperCase();
2622
+ // The HOST's own User-Agent (not the one sent to Anthropic): it
2623
+ // names the host's @ai-sdk/anthropic version, which decides
2624
+ // whether the L5 host-compat stream shim must run.
2625
+ const hostCompatShim = shouldApplyHostCompatShim(getIncomingHeader(input, requestInit, "user-agent"));
2626
+ /** @param {{blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}} shimStats */
2627
+ const onHostCompatShim = (shimStats) =>
2628
+ shimStats.dryRun
2629
+ ? debugLog(
2630
+ "host-compat shim disabled for this host SDK version, but would have rewritten the response stream (dry run) — allowlists may need re-extracting",
2631
+ shimStats,
2632
+ )
2633
+ : debugLog("host-compat shim rewrote the response stream", shimStats);
2595
2634
  let showUsageToast;
2596
2635
  try {
2597
2636
  showUsageToast = new URL(requestUrl).pathname === "/v1/messages" && requestMethod === "POST";
@@ -2699,10 +2738,25 @@ export async function AnthropicAuthPlugin({ client }) {
2699
2738
  let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
2700
2739
  let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
2701
2740
  let customBetasStripped = false; // One-shot latch: strip config.custom_betas once per logical request
2741
+ let sentBetaRejectionRetried = false; // One-shot latch: retry once after latching a rejected SENT beta
2742
+ /** @type {{key: string, betas: string[], recordedAt: number} | null} */
2743
+ let pendingSentBetaLatch = null; // Entries latched for the retry in flight; dropped if it fails the same way
2744
+ // One-shot: the account that just received a beta rejection. The
2745
+ // beta-rejection retry (`attempt--; continue`) must land on it: the
2746
+ // sent-beta latch is keyed per account, so a rotating strategy
2747
+ // (round-robin, hybrid) moving the retry to another account would
2748
+ // resend the rejected beta and burn the single retry.
2749
+ let betaRetryAccount = null;
2702
2750
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2703
- // Select account — use pinned account on first attempt if available
2704
- const account =
2705
- attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2751
+ // Select account — a beta-rejection retry stays on the rejecting
2752
+ // account (QA fix L5: only if it's still enabled and still
2753
+ // tracked by the account manager — see isBetaRetryAccountUsable);
2754
+ // otherwise use the pinned account on the first attempt.
2755
+ const _betaRetryAccount = betaRetryAccount;
2756
+ betaRetryAccount = null;
2757
+ const account = isBetaRetryAccountUsable(_betaRetryAccount, accountManager, transientRefreshSkips)
2758
+ ? _betaRetryAccount
2759
+ : attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2706
2760
  ? pinnedAccount
2707
2761
  : accountManager.getCurrentAccount(transientRefreshSkips);
2708
2762
 
@@ -2937,6 +2991,29 @@ export async function AnthropicAuthPlugin({ client }) {
2937
2991
  }
2938
2992
  return false;
2939
2993
  });
2994
+ // Evicting a rejected beta from `customBetas` is not enough on the
2995
+ // adapter path: the package composes some betas on its own (e.g.
2996
+ // `cache-diagnosis-2026-04-07` under 2.1.280), and only
2997
+ // `suppressBetas` (its terminal filter) can take those off the
2998
+ // wire. But suppression also removes the package's OWN copy of a
2999
+ // beta (e.g. context-1m under adaptive 1M), so it is reserved for
3000
+ // betas the API explicitly named as invalid for THIS account: the
3001
+ // unexpired entries of `sentBetaSuppressionsByAccount`. The
3002
+ // `customBetasStripped` one-shot and `sessionRejectedBetas` only
3003
+ // filter `customBetas` above, exactly as before 2.1.280. The
3004
+ // adapter never suppresses UNSUPPRESSIBLE_BETAS or
3005
+ // BODY_COUPLED_BETAS from this list.
3006
+ const _sessionRejectedBetaNames = new Set();
3007
+ const _accountSentBetaLatch = sentBetaSuppressionsByAccount.get(sentBetaLatchKey(account));
3008
+ if (_accountSentBetaLatch) {
3009
+ for (const [beta, rejectedAt] of _accountSentBetaLatch) {
3010
+ if (Date.now() - rejectedAt > SESSION_REJECTED_BETA_TTL_MS) {
3011
+ _accountSentBetaLatch.delete(beta);
3012
+ } else {
3013
+ _sessionRejectedBetaNames.add(beta);
3014
+ }
3015
+ }
3016
+ }
2940
3017
  // PHASE 2.2.3 — THE BETA LATCH IS GONE. It used to merge every
2941
3018
  // beta ever sent this session back into this value, to avoid
2942
3019
  // server-side cache-key churn from a beta flipping mid-session.
@@ -3155,6 +3232,7 @@ export async function AnthropicAuthPlugin({ client }) {
3155
3232
  enabled: getSignatureEmulationEnabled(),
3156
3233
  claudeCliVersion,
3157
3234
  customBetas: _sessionFilteredCustomBetas,
3235
+ rejectedBetas: [..._sessionRejectedBetaNames],
3158
3236
  strategy: getEffectiveStrategy(),
3159
3237
  sessionId: signatureSessionId,
3160
3238
  };
@@ -3705,43 +3783,121 @@ export async function AnthropicAuthPlugin({ client }) {
3705
3783
  // context_window, long context, 1m/million context) to avoid false positives
3706
3784
  // on generic 413s (e.g. plain upload-size limits with an empty body).
3707
3785
  // One retry per logical request (latch prevents loop).
3708
- if (
3709
- !customBetasStripped &&
3710
- (config.custom_betas?.length ?? 0) > 0 &&
3711
- ((response.status === 400 &&
3786
+ const _betaRejectionSignal =
3787
+ (response.status === 400 &&
3712
3788
  errorBody &&
3713
3789
  errorBody.includes("anthropic-beta") &&
3714
3790
  !errorBody.includes("context-hint")) ||
3715
- (response.status === 413 &&
3716
- errorBody &&
3717
- /anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
3718
- errorBody,
3719
- )))
3720
- ) {
3791
+ (response.status === 413 &&
3792
+ errorBody &&
3793
+ /anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
3794
+ errorBody,
3795
+ ));
3796
+ // The retry that followed a sent-beta latch failed the same way on
3797
+ // the SAME account (so the suppression was actually applied): it
3798
+ // did not help, so drop the entries just latched instead of
3799
+ // carrying a useless 5-minute suppression.
3800
+ const _latchUnderTest = pendingSentBetaLatch;
3801
+ pendingSentBetaLatch = null;
3802
+ if (_betaRejectionSignal && _latchUnderTest && _latchUnderTest.key === sentBetaLatchKey(account)) {
3803
+ const _latched = sentBetaSuppressionsByAccount.get(_latchUnderTest.key);
3804
+ for (const _beta of _latchUnderTest.betas) {
3805
+ if (_latched?.get(_beta) === _latchUnderTest.recordedAt) _latched.delete(_beta);
3806
+ }
3807
+ debugLog("sent beta latch did not clear the rejection - dropped", _latchUnderTest.betas);
3808
+ }
3809
+ // Betas the API's invalid-beta message ("Unexpected value(s) `X`
3810
+ // for the `anthropic-beta` header") names that were actually on
3811
+ // the failing request's header. Under 2.1.280 the package
3812
+ // composes some betas by default (e.g. cache-diagnosis), so a
3813
+ // rejection can name a beta the user never configured; only the
3814
+ // adapter path can suppress those (via `rejectedBetas` ->
3815
+ // `suppressBetas`). `_sentLatchableBetas` excludes OAuth / Claude
3816
+ // Code (UNSUPPRESSIBLE_BETAS) and BODY_COUPLED_BETAS, and both
3817
+ // lists are empty when the error echoes the header (too many
3818
+ // matches). count_tokens is excluded: its input has no
3819
+ // suppressBetas, so a latch would not change its retry and would
3820
+ // only degrade the following /v1/messages requests.
3821
+ let _namedSentBetas = [];
3822
+ let _sentLatchableBetas = [];
3823
+ if (_betaRejectionSignal && _useAdapter && !_isCountTokens) {
3824
+ const _sentHeader =
3825
+ typeof requestHeaders?.get === "function"
3826
+ ? requestHeaders.get("anthropic-beta")
3827
+ : requestHeaders?.["anthropic-beta"];
3828
+ ({ namedSent: _namedSentBetas, latchable: _sentLatchableBetas } = selectLatchableRejectedBetas(
3829
+ _sentHeader,
3830
+ errorBody,
3831
+ ));
3832
+ }
3833
+ /**
3834
+ * Latch `_sentLatchableBetas` for the account that received the
3835
+ * rejection, and remember them so a failed retry can undo it.
3836
+ * @param {number} recordedAt
3837
+ */
3838
+ const _latchSentBetas = (recordedAt) => {
3839
+ const _key = sentBetaLatchKey(account);
3840
+ let _latched = sentBetaSuppressionsByAccount.get(_key);
3841
+ if (!_latched) {
3842
+ _latched = new Map();
3843
+ sentBetaSuppressionsByAccount.set(_key, _latched);
3844
+ }
3845
+ for (const _beta of _sentLatchableBetas) _latched.set(_beta, recordedAt);
3846
+ pendingSentBetaLatch = { key: _key, betas: [..._sentLatchableBetas], recordedAt };
3847
+ sentBetaRejectionRetried = true;
3848
+ };
3849
+ if (!customBetasStripped && (config.custom_betas?.length ?? 0) > 0 && _betaRejectionSignal) {
3721
3850
  customBetasStripped = true;
3722
3851
  // F4: record rejection in session latch so next logical request already
3723
- // omits the rejected beta without needing a first-fail.
3724
- // Only record betas explicitly mentioned in the error body (raw or canonical);
3725
- // fall back to recording all custom betas if the body names none specifically.
3852
+ // omits the rejected beta from `customBetas` without needing a first-fail.
3853
+ // When the invalid-beta message names betas that were sent, record only
3854
+ // the custom betas among them. Otherwise (no parsable names) fall back to
3855
+ // custom betas mentioned in the body, then to all custom betas. This latch
3856
+ // only filters `customBetas`; it never suppresses a package-composed beta.
3726
3857
  {
3727
3858
  const _allCustom = config.custom_betas ?? [];
3728
- const _mentioned = _allCustom.filter((_sb) => {
3729
- const _rawLc = _sb.toLowerCase();
3730
- const _canLc = resolveBetaShortcut(_sb).toLowerCase();
3859
+ let _toRecord;
3860
+ if (_namedSentBetas.length > 0) {
3861
+ const _namedLc = new Set(_namedSentBetas.map((_b) => _b.toLowerCase()));
3862
+ _toRecord = _allCustom.filter((_sb) => _namedLc.has(resolveBetaShortcut(_sb).toLowerCase()));
3863
+ } else {
3731
3864
  const _bodyLc = (errorBody || "").toLowerCase();
3732
- return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
3733
- });
3734
- const _toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
3865
+ const _mentioned = _allCustom.filter((_sb) => {
3866
+ const _rawLc = _sb.toLowerCase();
3867
+ const _canLc = resolveBetaShortcut(_sb).toLowerCase();
3868
+ return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
3869
+ });
3870
+ _toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
3871
+ }
3735
3872
  const _recordedAt = Date.now();
3736
3873
  for (const _sb of _toRecord) {
3737
3874
  sessionRejectedBetas.set(resolveBetaShortcut(_sb), _recordedAt);
3738
3875
  }
3876
+ // Same single retry also suppresses any named SENT beta, so it does
3877
+ // not take a second retry to clear a package-composed one.
3878
+ if (_sentLatchableBetas.length > 0) _latchSentBetas(_recordedAt);
3739
3879
  }
3740
3880
  attempt--;
3881
+ betaRetryAccount = account;
3741
3882
  debugLog("custom beta/context rejection - retrying without custom betas");
3742
3883
  continue;
3743
3884
  }
3744
3885
 
3886
+ // Sent-beta rejection retry: the error names a beta that was on the
3887
+ // wire but is not (only) a custom beta, e.g. the package-default
3888
+ // cache-diagnosis with empty custom_betas. Latch it for this account
3889
+ // (TTL'd, as above) and retry once; `sentBetaRejectionRetried`
3890
+ // prevents a loop. Like the custom-beta path, this runs before any
3891
+ // account-failure bookkeeping and on a non-ok response, so no stream
3892
+ // was delivered.
3893
+ if (!sentBetaRejectionRetried && _sentLatchableBetas.length > 0) {
3894
+ _latchSentBetas(Date.now());
3895
+ attempt--;
3896
+ betaRetryAccount = account;
3897
+ debugLog("sent beta rejection - retrying without", _sentLatchableBetas);
3898
+ continue;
3899
+ }
3900
+
3745
3901
  // Reactive compaction: on "prompt too long" error, trim oldest messages and retry once
3746
3902
  if (
3747
3903
  response.status === 400 &&
@@ -3909,7 +4065,10 @@ export async function AnthropicAuthPlugin({ client }) {
3909
4065
  status: response.status,
3910
4066
  errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
3911
4067
  });
3912
- return transformResponse(response, undefined, undefined, correlationId);
4068
+ return transformResponse(response, undefined, undefined, correlationId, {
4069
+ hostCompatShim,
4070
+ onHostCompatShim,
4071
+ });
3913
4072
  }
3914
4073
 
3915
4074
  const accountSpecific = isAccountSpecificError(response.status, errorBody);
@@ -4135,7 +4294,10 @@ export async function AnthropicAuthPlugin({ client }) {
4135
4294
  status: response.status,
4136
4295
  });
4137
4296
  }
4138
- return transformResponse(response, undefined, undefined, correlationId);
4297
+ return transformResponse(response, undefined, undefined, correlationId, {
4298
+ hostCompatShim,
4299
+ onHostCompatShim,
4300
+ });
4139
4301
  }
4140
4302
 
4141
4303
  // Success
@@ -4331,6 +4493,7 @@ export async function AnthropicAuthPlugin({ client }) {
4331
4493
  : null,
4332
4494
  accountErrorCallback,
4333
4495
  correlationId,
4496
+ { hostCompatShim, onHostCompatShim },
4334
4497
  );
4335
4498
  }
4336
4499
 
@@ -4560,24 +4723,6 @@ export async function AnthropicAuthPlugin({ client }) {
4560
4723
  /** Module-level config ref for functions outside AnthropicAuthPlugin closure. */
4561
4724
  let _pluginConfig = null;
4562
4725
 
4563
- const _debugSessionId = randomUUID().slice(0, 8);
4564
- let _debugReqSeq = 0;
4565
-
4566
- export function createDebugCorrelationId() {
4567
- return `${_debugSessionId}-${(++_debugReqSeq).toString(36).padStart(4, "0")}`;
4568
- }
4569
-
4570
- export function isDebugSinkEnabled(config, sink) {
4571
- return sink === "body" ? config.token_economy?.debug_dump_bodies === true : Boolean(config.debug);
4572
- }
4573
-
4574
- export function createDebugRequestDump(correlationId, timestamp, finalBody) {
4575
- return {
4576
- filename: `req-${timestamp}-${correlationId}.json`,
4577
- content: JSON.stringify({ correlationId, timestamp, bodyRedacted: redactString(finalBody) }),
4578
- };
4579
- }
4580
-
4581
4726
  async function writeSseCapture(correlationId, buf, truncated) {
4582
4727
  const fs = await import("node:fs");
4583
4728
  const path = await import("node:path");
@@ -4598,23 +4743,6 @@ async function writeSseCapture(correlationId, buf, truncated) {
4598
4743
  );
4599
4744
  }
4600
4745
 
4601
- export function createDebugOutgoingHeadersEntry(correlationId, timestamp, requestHeaders) {
4602
- return [
4603
- `\n=== ${timestamp} | corr=${correlationId} | OUTGOING request headers ===`,
4604
- JSON.stringify(redactSecrets(requestHeaders), null, 2),
4605
- "",
4606
- ].join("\n");
4607
- }
4608
-
4609
- export function createDebugResponseHeadersEntry(correlationId, timestamp, response, debugHeaders) {
4610
- return [
4611
- `\n=== ${timestamp} | corr=${correlationId} | status=${response.status} ok=${response.ok} account=${debugHeaders.account} mgr=${debugHeaders.accountManager} ===`,
4612
- `Rate-limit headers: ${JSON.stringify(debugHeaders.rateLimitHeaders, null, 2)}`,
4613
- `All headers: ${JSON.stringify(debugHeaders.allHeaders, null, 2)}`,
4614
- "",
4615
- ].join("\n");
4616
- }
4617
-
4618
4746
  // ---------------------------------------------------------------------------
4619
4747
  // Adaptive 1M context state
4620
4748
  // ---------------------------------------------------------------------------
@@ -6086,15 +6214,27 @@ function transformRequestUrl(input, emulateSignature = true) {
6086
6214
  * @param {((details: {reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [onAccountError]
6087
6215
  * Called if a mid-stream error looks account-specific
6088
6216
  * @param {string} [correlationId]
6217
+ * @param {object} [hostCompat] - L5 host-compat shim wiring.
6218
+ * @param {boolean} [hostCompat.hostCompatShim] - Run the shim (see
6219
+ * `shouldApplyHostCompatShim`); defaults to on.
6220
+ * @param {((stats: {blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}) => void) | null} [hostCompat.onHostCompatShim]
6089
6221
  * @returns {Response}
6090
6222
  */
6091
- function transformResponse(response, onUsage, onAccountError, correlationId) {
6223
+ function transformResponse(
6224
+ response,
6225
+ onUsage,
6226
+ onAccountError,
6227
+ correlationId,
6228
+ { hostCompatShim = true, onHostCompatShim = null } = {},
6229
+ ) {
6092
6230
  if (!response.body) return response;
6093
6231
  const idleTimeoutMs = resolveStreamIdleTimeoutMs(_pluginConfig);
6094
6232
  const captureEnabled = _pluginConfig?.token_economy?.debug_dump_bodies === true && Boolean(correlationId);
6095
6233
  const stream = createTransformedSSEStream(response, {
6096
6234
  onUsage,
6097
6235
  onAccountError,
6236
+ hostCompatShim,
6237
+ onHostCompatShim,
6098
6238
  correlationId,
6099
6239
  idleTimeoutMs,
6100
6240
  captureEnabled,
@@ -6103,6 +6243,13 @@ function transformResponse(response, onUsage, onAccountError, correlationId) {
6103
6243
 
6104
6244
  // Inject cache transparency headers (session-level, available before stream completes).
6105
6245
  const responseHeaders = new Headers(response.headers);
6246
+ // QA fix L3: undici already decoded `response.body` (e.g. gzip was
6247
+ // transparently inflated) before it ever reached createTransformedSSEStream,
6248
+ // so a Content-Length/Content-Encoding copied from the upstream response
6249
+ // describes the ORIGINAL (possibly compressed) wire bytes, not the stream
6250
+ // this Response actually serves — forwarding either would lie to the host.
6251
+ responseHeaders.delete("content-length");
6252
+ responseHeaders.delete("content-encoding");
6106
6253
  responseHeaders.set("x-opencode-cache-hit-rate", String(Math.round(getAverageCacheHitRate() * 1000) / 1000));
6107
6254
  responseHeaders.set("x-opencode-cache-read-total", String(sessionMetrics.totalCacheRead));
6108
6255
  responseHeaders.set("x-opencode-session-cost", String(Math.round(sessionMetrics.sessionCostUsd * 10000) / 10000));
package/lib/accounts.mjs CHANGED
@@ -1193,3 +1193,33 @@ export class AccountManager {
1193
1193
  };
1194
1194
  }
1195
1195
  }
1196
+
1197
+ /**
1198
+ * Whether a one-shot beta-rejection retry pin (`betaRetryAccount` in the
1199
+ * fetch interceptor's retry loop) should still be honored for the retry
1200
+ * attempt it was set for (QA fix L5). Extracted from the loop closure so it
1201
+ * can be unit-tested without bootstrapping the full plugin.
1202
+ *
1203
+ * The pin is a live object reference captured when the *previous* attempt's
1204
+ * response named a rejected beta; by the time the retry lands, that same
1205
+ * account could have been disabled (CLI toggle, a terminal token-refresh
1206
+ * failure) or removed outright (account manager reload/edit) — either way it
1207
+ * must not be force-selected, or the retry could land on an account the rest
1208
+ * of the plugin no longer considers usable. `enabled` is read straight off
1209
+ * `betaRetryAccount` (accounts are mutated in place, so a live reference
1210
+ * reflects the current state), and "present in the account manager" is
1211
+ * checked by identity — not just index — since `removeAccount` re-indexes
1212
+ * the accounts that remain, so a stale index could otherwise coincidentally
1213
+ * match a different, unrelated account.
1214
+ *
1215
+ * @param {{index: number, enabled: boolean} | null} betaRetryAccount
1216
+ * @param {AccountManager} accountManager
1217
+ * @param {Set<number>} transientRefreshSkips - Indices excluded for the rest of this request.
1218
+ * @returns {boolean}
1219
+ */
1220
+ export function isBetaRetryAccountUsable(betaRetryAccount, accountManager, transientRefreshSkips) {
1221
+ if (!betaRetryAccount) return false;
1222
+ if (transientRefreshSkips.has(betaRetryAccount.index)) return false;
1223
+ if (!betaRetryAccount.enabled) return false;
1224
+ return accountManager.getEnabledAccounts().includes(betaRetryAccount);
1225
+ }
package/lib/betas.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * ADDS or SUPPRESSES on top of them.
14
14
  *
15
15
  * RECONCILIATION IS ENFORCED BY TEST, not by convention. Every literal header
16
- * below is checked against the package's `BETA_REGISTRY_2_1_233` — the genuine
16
+ * below is checked against the package's `BETA_REGISTRY_2_1_280` — the genuine
17
17
  * client's own registry — by `lib/betas.test.mjs`, which fails on a stale date
18
18
  * stamp and requires the host-only entries (the ones upstream has no registry
19
19
  * slot for) to be enumerated and justified rather than merely accumulated. Add
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Debug-dump helpers for the request/response debug sinks (`config.debug`,
3
+ * `token_economy.debug_dump_bodies`).
4
+ *
5
+ * These are plain helper functions, NOT plugin factories. They must never be
6
+ * re-exported from `index.mjs`: opencode's plugin loader iterates
7
+ * `Object.values(mod)` of the plugin entry module and calls every export as a
8
+ * plugin factory with the plugin input object (`{ client, ... }`, no
9
+ * `response`), then throws "Plugin export is not a function" / crashes on
10
+ * `response.status` for any export that isn't one. See
11
+ * `test/conformance/plugin-entry-exports.test.mjs`.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { redactSecrets, redactString } from "./redact.mjs";
15
+
16
+ const _debugSessionId = randomUUID().slice(0, 8);
17
+ let _debugReqSeq = 0;
18
+
19
+ /**
20
+ * Create a short, human-scannable id correlating the outgoing request debug
21
+ * dump with its response debug dump.
22
+ *
23
+ * @returns {string}
24
+ */
25
+ export function createDebugCorrelationId() {
26
+ return `${_debugSessionId}-${(++_debugReqSeq).toString(36).padStart(4, "0")}`;
27
+ }
28
+
29
+ /**
30
+ * Whether a given debug sink is enabled for the current config.
31
+ *
32
+ * @param {any} config
33
+ * @param {"body"|"headers"} sink
34
+ * @returns {boolean}
35
+ */
36
+ export function isDebugSinkEnabled(config, sink) {
37
+ return sink === "body" ? config.token_economy?.debug_dump_bodies === true : Boolean(config.debug);
38
+ }
39
+
40
+ /**
41
+ * Build the {filename, content} pair for a request-body debug dump.
42
+ *
43
+ * @param {string} correlationId
44
+ * @param {string} timestamp
45
+ * @param {string} finalBody
46
+ * @returns {{filename: string, content: string}}
47
+ */
48
+ export function createDebugRequestDump(correlationId, timestamp, finalBody) {
49
+ return {
50
+ filename: `req-${timestamp}-${correlationId}.json`,
51
+ content: JSON.stringify({ correlationId, timestamp, bodyRedacted: redactString(finalBody) }),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Build a log-file entry describing the outgoing request headers.
57
+ *
58
+ * @param {string} correlationId
59
+ * @param {string} timestamp
60
+ * @param {any} requestHeaders
61
+ * @returns {string}
62
+ */
63
+ export function createDebugOutgoingHeadersEntry(correlationId, timestamp, requestHeaders) {
64
+ return [
65
+ `\n=== ${timestamp} | corr=${correlationId} | OUTGOING request headers ===`,
66
+ JSON.stringify(redactSecrets(requestHeaders), null, 2),
67
+ "",
68
+ ].join("\n");
69
+ }
70
+
71
+ /**
72
+ * Build a log-file entry describing the response status/headers.
73
+ *
74
+ * @param {string} correlationId
75
+ * @param {string} timestamp
76
+ * @param {Response} response
77
+ * @param {{account: boolean, accountManager: boolean, rateLimitHeaders: any, allHeaders: any}} debugHeaders
78
+ * @returns {string}
79
+ */
80
+ export function createDebugResponseHeadersEntry(correlationId, timestamp, response, debugHeaders) {
81
+ return [
82
+ `\n=== ${timestamp} | corr=${correlationId} | status=${response.status} ok=${response.ok} account=${debugHeaders.account} mgr=${debugHeaders.accountManager} ===`,
83
+ `Rate-limit headers: ${JSON.stringify(debugHeaders.rateLimitHeaders, null, 2)}`,
84
+ `All headers: ${JSON.stringify(debugHeaders.allHeaders, null, 2)}`,
85
+ "",
86
+ ].join("\n");
87
+ }
package/lib/diagnose.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { getConfigDir, loadConfig } from "./config.mjs";
7
7
  import { redactSecrets } from "./redact.mjs";
8
- import { WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
8
+ import { getWireCompatPackageVersion, WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
9
9
  import { loadAccounts } from "./storage.mjs";
10
10
 
11
11
  const ENV_KEYS = [
@@ -175,6 +175,13 @@ export async function buildDiagnosticBundle() {
175
175
  // Static baseline on purpose: the CLI version the wire package's profile
176
176
  // declares, NOT the (possibly npm-updated) version of the running session.
177
177
  mimicryBaseline: WIRE_PROFILE.cliVersion,
178
+ // The full profile id (e.g. "claude-code-2.1.280-sdk-0.112.1"), same
179
+ // baseline caveat as mimicryBaseline above.
180
+ mimicryProfileId: WIRE_PROFILE.id,
181
+ // What's ACTUALLY loaded in node_modules right now -- see
182
+ // getWireCompatPackageVersion's own doc comment for why this can differ
183
+ // from what a future `npm ci` would install.
184
+ wireCompatPackageVersion: getWireCompatPackageVersion(),
178
185
  },
179
186
  config: redactSecrets(loadConfig()),
180
187
  env: getRuntimeEnv(),