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 +210 -63
- package/lib/accounts.mjs +30 -0
- package/lib/betas.mjs +1 -1
- package/lib/debug-dump.mjs +87 -0
- package/lib/diagnose.mjs +8 -1
- package/lib/mimicry/adapter-input.mjs +177 -15
- 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/rolling-summarizer.mjs +20 -5
- package/lib/unicode-text.mjs +111 -0
- package/package.json +4 -2
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,
|
|
@@ -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
|
-
|
|
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 —
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
-
|
|
3709
|
-
|
|
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
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
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
|
-
//
|
|
3725
|
-
//
|
|
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
|
-
|
|
3729
|
-
|
|
3730
|
-
const
|
|
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
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
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(
|
|
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 `
|
|
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(),
|