opencode-anthropic-fix 0.2.0 → 0.3.0-beta.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.
Files changed (43) hide show
  1. package/README.md +31 -6
  2. package/cli.mjs +1 -0
  3. package/index.mjs +160 -231
  4. package/lib/backoff.mjs +1 -1
  5. package/lib/cc-credentials.mjs +1 -1
  6. package/lib/config.mjs +49 -27
  7. package/lib/mimicry/adapter-input.mjs +671 -0
  8. package/lib/mimicry/context-hint-threshold.mjs +127 -0
  9. package/lib/mimicry/headers.mjs +71 -66
  10. package/lib/mimicry/request-body.mjs +23 -24
  11. package/lib/mimicry/system-prompt.mjs +54 -23
  12. package/lib/mimicry/wire-compat.mjs +305 -0
  13. package/lib/rotation.mjs +16 -0
  14. package/package.json +3 -3
  15. package/lib/account-state.test.mjs +0 -109
  16. package/lib/accounts.test.mjs +0 -912
  17. package/lib/adaptive-thinking-models.test.mjs +0 -30
  18. package/lib/backoff.test.mjs +0 -411
  19. package/lib/cc-credentials.test.mjs +0 -391
  20. package/lib/config.test.mjs +0 -757
  21. package/lib/context-hint-persist.mjs +0 -68
  22. package/lib/diagnose.test.mjs +0 -122
  23. package/lib/env.test.mjs +0 -25
  24. package/lib/haiku-call.test.mjs +0 -72
  25. package/lib/message-transform.test.mjs +0 -173
  26. package/lib/mimicry/cache.test.mjs +0 -112
  27. package/lib/mimicry/headers.test.mjs +0 -50
  28. package/lib/mimicry/models.test.mjs +0 -132
  29. package/lib/mimicry/request-body.test.mjs +0 -34
  30. package/lib/mimicry/request-helpers.test.mjs +0 -155
  31. package/lib/mimicry/response-stream.test.mjs +0 -127
  32. package/lib/mimicry/system-prompt.test.mjs +0 -73
  33. package/lib/oauth.test.mjs +0 -343
  34. package/lib/redact.test.mjs +0 -154
  35. package/lib/refresh-lock.test.mjs +0 -131
  36. package/lib/request-headers.test.mjs +0 -164
  37. package/lib/retry/overload-loop.test.mjs +0 -107
  38. package/lib/rotation.test.mjs +0 -346
  39. package/lib/session-metrics.test.mjs +0 -67
  40. package/lib/storage.test.mjs +0 -696
  41. package/lib/token-economy/microcompact.test.mjs +0 -28
  42. package/lib/token-economy/transforms.test.mjs +0 -192
  43. package/lib/tuning.test.mjs +0 -89
package/index.mjs CHANGED
@@ -3,7 +3,6 @@ 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 xxhashInit from "xxhash-wasm";
7
6
  import { AccountManager, RATE_LIMIT_KEY_FAST } from "./lib/accounts.mjs";
8
7
  import {
9
8
  authorize as oauthAuthorize,
@@ -15,15 +14,10 @@ import {
15
14
  FALLBACK_CLAUDE_CLI_VERSION,
16
15
  CLAUDE_CODE_NPM_LATEST_URL,
17
16
  CLAUDE_CODE_BUILD_TIME,
18
- EXPERIMENTAL_BETA_FLAGS,
19
- BETA_SHORTCUTS,
20
17
  resolveBetaShortcut,
21
- buildExtendedUserAgent,
22
18
  } from "./lib/request-headers.mjs";
23
- import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
24
- import { loadContextHintDisabledFlag, saveContextHintDisabledFlag } from "./lib/context-hint-persist.mjs";
19
+ import { loadConfig, loadConfigFresh, saveConfig, getConfigDir } from "./lib/config.mjs";
25
20
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
26
- import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
27
21
  import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
28
22
  import {
29
23
  isAccountSpecificError,
@@ -51,7 +45,7 @@ import {
51
45
  stripMcpPrefixFromParsedEvent,
52
46
  } from "./lib/mimicry/response-stream.mjs";
53
47
  import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
54
- import { isFalsyEnv, isTruthyEnv } from "./lib/env.mjs";
48
+ import { isTruthyEnv } from "./lib/env.mjs";
55
49
  import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
56
50
  import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
57
51
  import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
@@ -66,8 +60,8 @@ import {
66
60
  buildRequestHeaders,
67
61
  buildAnthropicBetaHeader,
68
62
  parseRequestBodyMetadata,
69
- detectProvider,
70
63
  extractFileIds,
64
+ stripStainlessHelperMarkers,
71
65
  } from "./lib/mimicry/headers.mjs";
72
66
  import {
73
67
  buildSystemPromptBlocks,
@@ -82,14 +76,14 @@ import {
82
76
  CLAUDE_CODE_IDENTITY_STRING,
83
77
  SUBAGENT_CC_ANCHOR,
84
78
  } from "./lib/mimicry/system-prompt.mjs";
79
+ import { buildAdapterTransport, resolveAdapterEnv } from "./lib/mimicry/adapter-input.mjs";
80
+ import { buildWireCompatibleRequest } from "./lib/mimicry/wire-compat.mjs";
85
81
  import {
86
- CLAUDE_3_MODEL_RE,
87
82
  hasOneMillionContext,
88
83
  isEligibleFor1MContext,
89
84
  isOpus46Model,
90
85
  isOpus47Model,
91
86
  isOpus48Model,
92
- isSonnet46Model,
93
87
  } from "./lib/mimicry/models.mjs";
94
88
 
95
89
  export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/models.mjs";
@@ -241,7 +235,7 @@ async function runHaikuSessionSummarize(
241
235
  }
242
236
  }
243
237
 
244
- export async function AnthropicAuthPlugin({ client, project, directory, worktree, serverUrl, $ }) {
238
+ export async function AnthropicAuthPlugin({ client }) {
245
239
  const config = loadConfig();
246
240
  _pluginConfig = config; // expose to module-level functions (cache stats, response headers)
247
241
  // QA fix H6: read emulation settings live from config instead of stale const capture
@@ -325,34 +319,6 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
325
319
  /** @type {Map<string, number>} canonical-beta to rejected-at epoch ms */
326
320
  const sessionRejectedBetas = new Map();
327
321
 
328
- // Context-hint controller (CC v2.1.110+). Mirrors real CC's `createContextHintController`:
329
- // sticky on across requests until the server responds with a specific error family.
330
- // - 422/424 → apply hint compaction (clear thinking + microcompact) and retry
331
- // - 400 "Unexpected value" + "anthropic-beta" → disable for session (beta unsupported)
332
- // - 409 → disable for session (conflict)
333
- // - 529 / overloaded → disable for session (temporary overload)
334
- // When disabled, we strip context-hint from betas + body on subsequent requests so
335
- // we don't keep triggering the same rejection and churning the cache.
336
- // Persisted disable: if a prior session saw a 400 rejecting the context-hint
337
- // beta (account lacks access), skip the beta from turn 1 of every subsequent
338
- // session. Delete ~/.config/opencode/context-hint-disabled.flag (or the
339
- // %APPDATA%\opencode equivalent on Windows) to re-enable once access is
340
- // granted. See lib/context-hint-persist.mjs.
341
- const _persistedCtxHint = loadContextHintDisabledFlag();
342
- const contextHintState = {
343
- /** Permanently disabled for this session after a server rejection. */
344
- disabled: _persistedCtxHint.disabled === true,
345
- /** Number of 422/424 compactions applied this session (for telemetry). */
346
- compactionsApplied: 0,
347
- };
348
- if (contextHintState.disabled) {
349
- debugLog(
350
- "context-hint: loaded persisted disable flag",
351
- _persistedCtxHint.status ? `status=${_persistedCtxHint.status}` : "",
352
- _persistedCtxHint.timestamp ? `ts=${new Date(_persistedCtxHint.timestamp).toISOString()}` : "",
353
- );
354
- }
355
-
356
322
  // Token economy — session state for layered compaction strategies.
357
323
  const tokenEconomySession = {
358
324
  /** When thinking was last stripped (TTL-based strategy). 0 = never. */
@@ -842,7 +808,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
842
808
  `telemetry-emulation: ${fresh.telemetry?.emulate_minimal ? "on (silent observer)" : "off"}`,
843
809
  `usage-toast: ${fresh.usage_toast ? "on" : "off"}`,
844
810
  `adaptive-context: ${fresh.adaptive_context?.enabled ? `on (↑${Math.round((fresh.adaptive_context.escalation_threshold || 150000) / 1000)}K ↓${Math.round((fresh.adaptive_context.deescalation_threshold || 100000) / 1000)}K)${adaptiveContextState.active ? " [ACTIVE]" : ""}` : "off"}`,
845
- `anti-verbosity: ${fresh.anti_verbosity?.enabled !== false ? "on" : "off"} (length-anchors: ${fresh.anti_verbosity?.length_anchors !== false ? "on" : "off"})`,
811
+ `anti-verbosity: ${fresh.anti_verbosity?.enabled !== false ? "on" : "off"}`,
846
812
  ];
847
813
  await sendCommandMessage(input.sessionID, lines.join("\n"));
848
814
  return;
@@ -1191,7 +1157,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1191
1157
  config.toasts.quiet = enabled;
1192
1158
  },
1193
1159
  strategy: () => {
1194
- const valid = ["sticky", "round-robin", "hybrid"];
1160
+ const valid = ["sticky", "round-robin", "hybrid", "single"];
1195
1161
  if (valid.includes(value)) {
1196
1162
  saveConfig({ account_selection_strategy: value });
1197
1163
  strategyState.manualOverride = true;
@@ -2881,7 +2847,6 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2881
2847
  requestInit.body,
2882
2848
  _parsedBodyOnce,
2883
2849
  );
2884
- const _reqProvider = detectProvider(requestUrl);
2885
2850
 
2886
2851
  // --- Adaptive 1M context decision (once per logical request, not per retry) ---
2887
2852
  if (!_adaptiveDecisionMade) {
@@ -2916,12 +2881,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2916
2881
  const _adaptiveOverride = _adaptiveOverrideForRequest;
2917
2882
 
2918
2883
  // Token economy config (resolved once, passed to beta builder).
2919
- // If the server has rejected context-hint this session, reflect it here
2920
- // so buildAnthropicBetaHeader (called again inside buildRequestHeaders)
2921
- // drops the beta on subsequent requests.
2922
- // Also classify the request role (CC's querySource analog) and
2923
- // suppress context-hint for non-main-thread requests so we don't
2924
- // opt subagent/title/tool one-shots into a server retry loop.
2884
+ // Also classify the request role (CC's querySource analog).
2925
2885
  const _requestRole = classifyRequestRole(_parsedBodyOnce);
2926
2886
  // opencode tags subagent requests with the x-parent-session-id header
2927
2887
  // (set in its request builder only when a parent session exists).
@@ -2930,10 +2890,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2930
2890
  // only to pick the cheaper 5m cache tier for one-shot subagents.
2931
2891
  const _isSubagent = getIncomingHeader(input, requestInit, "x-parent-session-id") != null;
2932
2892
  const _baseTE = config.token_economy || {};
2933
- const _disableCtxHint = contextHintState.disabled || _requestRole !== "main";
2934
- const _tokenEconomy = _disableCtxHint
2935
- ? { ..._baseTE, context_hint: false, __requestRole: _requestRole }
2936
- : { ..._baseTE, __requestRole: _requestRole };
2893
+ const _tokenEconomy = { ..._baseTE, __requestRole: _requestRole };
2937
2894
 
2938
2895
  // Microcompact: inject clear betas at high context utilization
2939
2896
  let _microcompactBetas = null;
@@ -2975,7 +2932,6 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2975
2932
  "",
2976
2933
  getSignatureEmulationEnabled(),
2977
2934
  _reqModel,
2978
- _reqProvider,
2979
2935
  _sessionFilteredCustomBetas,
2980
2936
  getEffectiveStrategy(),
2981
2937
  requestUrl?.pathname,
@@ -3004,12 +2960,6 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3004
2960
  // Merge latched betas that aren't in the current set
3005
2961
  const merged = new Set(currentBetas);
3006
2962
  for (const b of betaLatchState.sent) merged.add(b);
3007
- // Context-hint kill switch: once server rejected it this session,
3008
- // stop sending the beta (body field is gated on header, so it drops too).
3009
- if (contextHintState.disabled) {
3010
- merged.delete("context-hint-2026-04-09");
3011
- betaLatchState.sent.delete("context-hint-2026-04-09");
3012
- }
3013
2963
  // Custom-beta strip kill switch: when the server rejected our custom
3014
2964
  // betas (customBetasStripped latch fired), evict them from the latch so
3015
2965
  // they do not re-appear on the retry attempt.
@@ -3052,13 +3002,38 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3052
3002
  const effectiveCachePolicy = latchedCachePolicy ||
3053
3003
  config.cache_policy || { ttl: "1h", ttl_supported: true };
3054
3004
 
3005
+ // Single decision point for the whole request: does this turn go
3006
+ // through the shared wire package, or through the legacy
3007
+ // buildRequestHeaders path?
3008
+ //
3009
+ // Two conditions remain now that multi-provider support is gone:
3010
+ // 1. signature emulation on — with it off the plugin emits only
3011
+ // 3 headers and a minimal beta set, while the package always
3012
+ // emits the full Claude Code set;
3013
+ // 2. the /v1/messages endpoint — the package pins
3014
+ // `https://api.anthropic.com/v1/messages?beta=true`, so a
3015
+ // /v1/messages/count_tokens turn sent through it would be
3016
+ // silently rewritten to the wrong endpoint.
3017
+ //
3018
+ // The former first-party check is now structural: the plugin only
3019
+ // speaks to first-party Anthropic, so it is always satisfied.
3020
+ // 3. a JSON body is actually present — the package requires
3021
+ // `model` and `max_tokens`, so a bodiless request must keep
3022
+ // using the legacy path rather than throwing INVALID_INPUT.
3023
+ const _adapterPathname = requestUrl?.pathname;
3024
+ const _useAdapter =
3025
+ getSignatureEmulationEnabled() &&
3026
+ (_adapterPathname === "/v1/messages" || _adapterPathname === "/messages") &&
3027
+ typeof requestInit.body === "string" &&
3028
+ requestInit.body.length > 0;
3029
+
3055
3030
  const body = transformRequestBody(
3056
3031
  requestInit.body,
3057
3032
  {
3058
3033
  enabled: getSignatureEmulationEnabled(),
3059
3034
  claudeCliVersion,
3060
3035
  promptCompactionMode: getPromptCompactionMode(),
3061
- provider: _reqProvider,
3036
+ useAdapter: _useAdapter,
3062
3037
  cachePolicy: effectiveCachePolicy,
3063
3038
  fastMode: config.fast_mode || false,
3064
3039
  // When the account's fast pool is cooling down, suppress
@@ -3127,29 +3102,107 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3127
3102
  }
3128
3103
  }
3129
3104
 
3130
- // Build headers with the selected account's token
3131
- const requestHeaders = buildRequestHeaders(
3132
- input,
3133
- requestInit,
3134
- accessToken,
3135
- body,
3136
- requestUrl,
3137
- {
3138
- enabled: getSignatureEmulationEnabled(),
3139
- claudeCliVersion,
3140
- customBetas: _sessionFilteredCustomBetas,
3141
- strategy: getEffectiveStrategy(),
3142
- sessionId: signatureSessionId,
3143
- },
3144
- _adaptiveOverride,
3145
- _tokenEconomy,
3146
- );
3105
+ // Build headers with the selected account's token.
3106
+ //
3107
+ // Adapter path: the shared package composes the headers AND
3108
+ // rebuilds the body (canonical system prefix, metadata, beta
3109
+ // header). Legacy path: unchanged, byte for byte.
3110
+ //
3111
+ // The URL deliberately stays whatever transformRequestUrl already
3112
+ // produced. `built.url` is the package's pinned
3113
+ // `https://api.anthropic.com/v1/messages?beta=true`, which would
3114
+ // override a custom ANTHROPIC_BASE_URL or proxy endpoint.
3115
+ const _adapterSignature = {
3116
+ enabled: getSignatureEmulationEnabled(),
3117
+ claudeCliVersion,
3118
+ customBetas: _sessionFilteredCustomBetas,
3119
+ strategy: getEffectiveStrategy(),
3120
+ sessionId: signatureSessionId,
3121
+ };
3122
+
3123
+ let requestHeaders;
3124
+ let adapterBody;
3125
+ let legacyStrippedBody;
3126
+ if (_useAdapter) {
3127
+ const _adapterResult = buildAdapterTransport({
3128
+ input,
3129
+ requestInit,
3130
+ accessToken,
3131
+ requestUrl,
3132
+ provider: "anthropic",
3133
+ clientRequestId: randomUUID(),
3134
+ signature: _adapterSignature,
3135
+ identity: {
3136
+ persistentUserId: signatureUserId,
3137
+ accountId: getAccountIdentifier(account),
3138
+ },
3139
+ adaptiveOverride: _adaptiveOverride,
3140
+ tokenEconomy: _tokenEconomy,
3141
+ // Role inputs for the identity-block cache ttl: the adapter
3142
+ // re-runs the same resolveCacheTtl the body transform used,
3143
+ // so system[1] cannot carry a ttl the rest of the request
3144
+ // does not.
3145
+ cachePolicy: effectiveCachePolicy,
3146
+ requestRole: _requestRole,
3147
+ isSubagent: _isSubagent,
3148
+ // Derived from the PRE-transform body on purpose: the lean
3149
+ // gate in buildSystemPromptBlocks tests the incoming system
3150
+ // blocks, and by now the title-generator swap has already
3151
+ // rewritten them in `body`.
3152
+ isTitleGenerator: _parsedBodyOnce
3153
+ ? isTitleGeneratorSystemBlocks(normalizeSystemTextBlocks(_parsedBodyOnce.system))
3154
+ : false,
3155
+ body,
3156
+ env: resolveAdapterEnv(process.env),
3157
+ platform: process.platform,
3158
+ arch: process.arch,
3159
+ nodeVersion: process.version,
3160
+ });
3161
+ if (_adapterResult.applicable) {
3162
+ const built = await buildWireCompatibleRequest(body, _adapterResult.transport);
3163
+ requestHeaders = built.headers;
3164
+ adapterBody = built.body;
3165
+ } else {
3166
+ debugLog(`adapter not applicable (${_adapterResult.reason}); using the legacy request path`);
3167
+ }
3168
+ }
3169
+
3170
+ if (!requestHeaders) {
3171
+ // Legacy path, and the fallback whenever the adapter declined:
3172
+ // a bodiless request, or a transport the translation refused.
3173
+ requestHeaders = buildRequestHeaders(
3174
+ input,
3175
+ requestInit,
3176
+ accessToken,
3177
+ body,
3178
+ requestUrl,
3179
+ _adapterSignature,
3180
+ _adaptiveOverride,
3181
+ _tokenEconomy,
3182
+ );
3183
+ // buildRequestHeaders just derived x-stainless-helper from the
3184
+ // markers. They are an internal signal — the Anthropic API has
3185
+ // never known those keys — so they are dropped from the body now
3186
+ // that the header exists. The adapter path does the same strip in
3187
+ // buildWireCompatibleRequest; this is the legacy half of it.
3188
+ if (typeof body === "string" && body.length > 0) {
3189
+ try {
3190
+ const _strippable = JSON.parse(body);
3191
+ if (stripStainlessHelperMarkers(_strippable?.tools, _strippable?.messages) > 0) {
3192
+ legacyStrippedBody = JSON.stringify(_strippable);
3193
+ }
3194
+ } catch (error) {
3195
+ // A non-JSON body carries no markers to strip; forward it untouched.
3196
+ debugLog(`stainless-helper strip skipped (unparsable body): ${error.message}`);
3197
+ }
3198
+ }
3199
+ }
3147
3200
  // cch stays as the static "00000" placeholder — cc-107 and cc-108
3148
3201
  // JS bundles both emit `cch=00000;` unconditionally in the billing
3149
3202
  // header. The Bun-binary Attestation.zig xxHash64 mechanism lives in
3150
3203
  // a SEPARATE header path, not in this body field. Re-hashing here
3151
3204
  // mutates system[0] each turn, invalidating the prompt cache.
3152
- const finalBody = body;
3205
+ const finalBody = adapterBody ?? legacyStrippedBody ?? body;
3153
3206
 
3154
3207
  const correlationId = createDebugCorrelationId();
3155
3208
 
@@ -3520,76 +3573,35 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3520
3573
  // Ignore read errors in debug logging path.
3521
3574
  }
3522
3575
 
3523
- // Context-hint protocol (CC v2.1.110+): detect server rejections and
3524
- // apply the same disable/compact semantics as real Claude Code.
3525
- // - 400 w/ "Unexpected value" + "anthropic-beta" → beta unsupported, disable
3526
- // - 409 / 529 / overloaded → temporary, disable
3527
- // - 422 / 424 → compact messages (strip
3528
- // thinking blocks + old tool_result content) and retry ONCE.
3529
- // Disable is permanent for the session; the beta latch strips it from future
3530
- // requests so we don't keep triggering the same rejection.
3531
- if (!contextHintState.disabled) {
3532
- if (
3533
- response.status === 400 &&
3534
- errorBody &&
3535
- errorBody.includes("Unexpected value") &&
3536
- errorBody.includes("anthropic-beta") &&
3537
- errorBody.includes("context-hint")
3538
- ) {
3539
- contextHintState.disabled = true;
3540
- betaLatchState.dirty = true;
3541
- // Persist across sessions: a 400 "Unexpected value" on the
3542
- // context-hint beta means the account lacks access to the
3543
- // beta entirely. Re-attempting next session would burn
3544
- // another turn to the same rejection.
3545
- saveContextHintDisabledFlag({
3546
- reason: "beta_unsupported_400",
3547
- status: 400,
3548
- });
3549
- debugLog("context-hint: beta rejected by server (400), disabling + persisting");
3550
- // Retry without the beta. The latch above drops the header
3551
- // and the body field on the rebuilt request.
3552
- attempt--;
3553
- continue;
3554
- } else if (response.status === 409) {
3555
- contextHintState.disabled = true;
3556
- betaLatchState.dirty = true;
3557
- debugLog("context-hint: 409 conflict, disabling for session");
3558
- attempt--;
3559
- continue;
3560
- } else if (response.status === 529 && errorBody && errorBody.includes("context_hint")) {
3561
- contextHintState.disabled = true;
3562
- betaLatchState.dirty = true;
3563
- debugLog("context-hint: 529 overloaded referencing hint, disabling for session");
3564
- attempt--;
3565
- continue;
3566
- } else if (
3567
- (response.status === 422 || response.status === 424) &&
3568
- !requestInit._contextHintCompactAttempted
3569
- ) {
3570
- try {
3571
- const hintBody = JSON.parse(requestInit.body);
3572
- if (Array.isArray(hintBody.messages)) {
3573
- const compacted = applyContextHintCompaction(hintBody.messages);
3574
- if (compacted.changed) {
3575
- hintBody.messages = compacted.messages;
3576
- requestInit.body = JSON.stringify(hintBody);
3577
- _parsedBodyOnce = null;
3578
- requestInit._contextHintCompactAttempted = true;
3579
- contextHintState.compactionsApplied += 1;
3580
- attempt--;
3581
- toast(
3582
- `⚙ Context hint compaction (${response.status}) — cleared ${compacted.stats.thinkingCleared} thinking / ${compacted.stats.toolResultsCleared} tool results`,
3583
- "info",
3584
- { debounceKey: "context-hint-compact" },
3585
- ).catch(() => {});
3586
- debugLog("context-hint: applied compaction on status", response.status, compacted.stats);
3587
- continue;
3588
- }
3576
+ // Compact-and-retry on 422/424: strip thinking blocks + old tool_result
3577
+ // content and retry ONCE. This reacts to the response status alone, so it
3578
+ // stays live even though the plugin no longer announces the
3579
+ // context-hint-2026-04-09 beta nor the paired body field.
3580
+ if (
3581
+ (response.status === 422 || response.status === 424) &&
3582
+ !requestInit._contextHintCompactAttempted
3583
+ ) {
3584
+ try {
3585
+ const hintBody = JSON.parse(requestInit.body);
3586
+ if (Array.isArray(hintBody.messages)) {
3587
+ const compacted = applyContextHintCompaction(hintBody.messages);
3588
+ if (compacted.changed) {
3589
+ hintBody.messages = compacted.messages;
3590
+ requestInit.body = JSON.stringify(hintBody);
3591
+ _parsedBodyOnce = null;
3592
+ requestInit._contextHintCompactAttempted = true;
3593
+ attempt--;
3594
+ toast(
3595
+ `⚙ Context hint compaction (${response.status}) — cleared ${compacted.stats.thinkingCleared} thinking / ${compacted.stats.toolResultsCleared} tool results`,
3596
+ "info",
3597
+ { debounceKey: "context-hint-compact" },
3598
+ ).catch(() => {});
3599
+ debugLog("context-hint: applied compaction on status", response.status, compacted.stats);
3600
+ continue;
3589
3601
  }
3590
- } catch {
3591
- // fall through to normal error handling
3592
3602
  }
3603
+ } catch {
3604
+ // fall through to normal error handling
3593
3605
  }
3594
3606
  }
3595
3607
 
@@ -5375,7 +5387,6 @@ function writeCacheStatsFile(usage, model, hitRate) {
5375
5387
  cache_ttl: _pluginConfig?.cache_policy?.ttl ?? "1h",
5376
5388
  boundary_marker: _pluginConfig?.cache_policy?.boundary_marker ?? false,
5377
5389
  anti_verbosity: _pluginConfig?.anti_verbosity?.enabled !== false,
5378
- length_anchors: _pluginConfig?.anti_verbosity?.length_anchors !== false,
5379
5390
  },
5380
5391
  timestamp: new Date().toISOString(),
5381
5392
  };
@@ -5572,34 +5583,6 @@ process.once("beforeExit", _beforeExitHandler);
5572
5583
  // Request building helpers (extracted from original fetch interceptor)
5573
5584
  // ---------------------------------------------------------------------------
5574
5585
 
5575
- // cch attestation: RE-ENABLED with xxHash64 (matching Bun binary's Attestation.zig).
5576
- // The compiled Bun binary computes cch dynamically: xxHash64(body, seed) & 0xFFFFF.
5577
- // Captured real CC v2.1.107 request shows cch=6d00f (5-hex-char, 20-bit masked hash).
5578
- // Seed extracted from binary: 0x6E52736AC806831E (unchanged since v2.1.96).
5579
- const CCH_SEED = 0x6e52736ac806831en; // BigInt — Attestation.zig seed
5580
-
5581
- /** @type {null | ((buf: Uint8Array, seed: bigint) => bigint)} */
5582
- let _xxh64Raw = null;
5583
- const _xxhashReady = xxhashInit().then((h) => {
5584
- _xxh64Raw = h.h64Raw;
5585
- });
5586
-
5587
- /**
5588
- * Compute and replace the cch=00000 placeholder in the serialized body with
5589
- * xxHash64(body, seed) & 0xFFFFF, matching the Bun binary's native attestation.
5590
- * @param {string} body - Serialized JSON body
5591
- * @returns {Promise<string>} Body with cch replaced
5592
- */
5593
- async function computeAndReplaceCCH(body) {
5594
- if (typeof body !== "string" || !body.includes("cch=00000")) return body;
5595
- await _xxhashReady;
5596
- if (!_xxh64Raw) return body; // fallback: send as-is if wasm failed to load
5597
- const bodyBytes = Buffer.from(body, "utf-8");
5598
- const hash = _xxh64Raw(bodyBytes, CCH_SEED);
5599
- const cch = (hash & 0xfffffn).toString(16).padStart(5, "0");
5600
- return body.replace("cch=00000", `cch=${cch}`);
5601
- }
5602
-
5603
5586
  /**
5604
5587
  * Extract the text content of the first user message for billing hash computation.
5605
5588
  * @param {any[] | undefined} messages
@@ -5700,13 +5683,6 @@ function shouldDebugSystemPrompt() {
5700
5683
  * @returns {Record<string, string>}
5701
5684
  */
5702
5685
 
5703
- /**
5704
- * @returns {string}
5705
- */
5706
- function getClaudeEntrypoint() {
5707
- return process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
5708
- }
5709
-
5710
5686
  /**
5711
5687
  * @param {string | undefined} model
5712
5688
  * @returns {string | undefined}
@@ -5733,15 +5709,6 @@ function logTransformedSystemPrompt(body) {
5733
5709
  * @returns {boolean}
5734
5710
  */
5735
5711
 
5736
- /**
5737
- * @param {string | undefined} body
5738
- * @returns {boolean}
5739
- */
5740
- function supportsThinking(model) {
5741
- if (!model) return true;
5742
- return /claude|sonnet|opus|haiku/i.test(model);
5743
- }
5744
-
5745
5712
  /**
5746
5713
  * @param {string | undefined} body
5747
5714
  * @returns {boolean}
@@ -5830,19 +5797,6 @@ function getAccountIdentifier(account) {
5830
5797
  * @returns {{user_id: string}}
5831
5798
  */
5832
5799
 
5833
- /**
5834
- * Return the cache_control object appropriate for the given cache policy.
5835
- * @param {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | undefined} cachePolicy
5836
- * @returns {{type: string, ttl?: string}}
5837
- */
5838
- function getCacheControlForPolicy(cachePolicy) {
5839
- if (!cachePolicy) return { type: "ephemeral" };
5840
- if (cachePolicy.ttl === "off" || cachePolicy.ttl_supported === false) {
5841
- return { type: "ephemeral" };
5842
- }
5843
- return { type: "ephemeral", ttl: cachePolicy.ttl };
5844
- }
5845
-
5846
5800
  /**
5847
5801
  * @param {string} incomingBeta
5848
5802
  * @param {boolean} signatureEnabled
@@ -5866,18 +5820,6 @@ function getCacheControlForPolicy(cachePolicy) {
5866
5820
 
5867
5821
  /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
5868
5822
 
5869
- /**
5870
- * Map budgetTokens to an effort level.
5871
- * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
5872
- * @param {number} budgetTokens
5873
- * @returns {ThinkingEffort}
5874
- */
5875
- function budgetTokensToEffort(budgetTokens) {
5876
- if (budgetTokens <= 1024) return "low";
5877
- if (budgetTokens <= 8000) return "medium";
5878
- return "high";
5879
- }
5880
-
5881
5823
  // QA fix L3: budgetTokensToEffort() removed — dead code, never called
5882
5824
  // QA fix L4: isValidEffort() removed — dead code, never called
5883
5825
 
@@ -6068,19 +6010,6 @@ function isEventStreamResponse(response) {
6068
6010
  return contentType.toLowerCase().includes("text/event-stream");
6069
6011
  }
6070
6012
 
6071
- /**
6072
- * Build user-facing switch reason text for account-specific errors.
6073
- * @param {number} status
6074
- * @param {import('./lib/backoff.mjs').RateLimitReason} reason
6075
- * @returns {string}
6076
- */
6077
- function formatSwitchReason(status, reason) {
6078
- if (reason === "AUTH_FAILED") return "auth failed";
6079
- if (status === 403 && reason === "QUOTA_EXHAUSTED") return "permission denied";
6080
- if (reason === "QUOTA_EXHAUSTED") return "quota exhausted";
6081
- return "rate-limited";
6082
- }
6083
-
6084
6013
  // ---------------------------------------------------------------------------
6085
6014
  // Token refresh (per-account)
6086
6015
  // ---------------------------------------------------------------------------
@@ -6153,7 +6082,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
6153
6082
  * @returns {Promise<string>} The new access token
6154
6083
  * @throws {Error} If refresh fails
6155
6084
  */
6156
- async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
6085
+ async function refreshAccountToken(account, client, _source = "foreground", { onTokensUpdated } = {}) {
6157
6086
  // CC-sourced accounts must NEVER enter the OAuth HTTP refresh flow.
6158
6087
  // Instead, re-read credentials from the CC source. If they're still
6159
6088
  // expired, let the caller handle it (the account will be skipped).
package/lib/backoff.mjs CHANGED
@@ -134,7 +134,7 @@ function extractErrorSignals(body) {
134
134
  * @returns {boolean}
135
135
  */
136
136
  function bodyHasAccountError(body) {
137
- const { errorType, message, text } = extractErrorSignals(body);
137
+ const { errorType, message } = extractErrorSignals(body);
138
138
 
139
139
  const typeSignals = [
140
140
  "rate_limit",
@@ -84,7 +84,7 @@ function readKeychainService(service) {
84
84
  }).trim();
85
85
 
86
86
  return parseCCCredentialData(raw, "cc-keychain", `keychain:${service}`);
87
- } catch (err) {
87
+ } catch {
88
88
  // Exit code 36 = user denied, 44 = not found, 128 = killed
89
89
  return null;
90
90
  }