opencode-anthropic-fix 0.0.30 → 0.0.32

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
@@ -590,6 +590,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
590
590
  `fast-mode: ${fresh.fast_mode ? "on" : "off"}`,
591
591
  `telemetry-emulation: ${fresh.telemetry?.emulate_minimal ? "on (silent observer)" : "off"}`,
592
592
  `usage-toast: ${fresh.usage_toast ? "on" : "off"}`,
593
+ `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"}`,
593
594
  ];
594
595
  await sendCommandMessage(input.sessionID, lines.join("\n"));
595
596
  return;
@@ -818,6 +819,22 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
818
819
  saveConfig({ usage_toast: enabled });
819
820
  config.usage_toast = enabled;
820
821
  },
822
+ "adaptive-context": () => {
823
+ const enabled = value === "on" || value === "true" || value === "1";
824
+ saveConfig({ adaptive_context: { enabled } });
825
+ if (!config.adaptive_context)
826
+ config.adaptive_context = {
827
+ enabled: false,
828
+ escalation_threshold: 150_000,
829
+ deescalation_threshold: 100_000,
830
+ };
831
+ config.adaptive_context.enabled = enabled;
832
+ // Reset state when toggled off
833
+ if (!enabled) {
834
+ adaptiveContextState.active = false;
835
+ adaptiveContextState.escalatedByError = false;
836
+ }
837
+ },
821
838
  };
822
839
 
823
840
  if (!key || !setters[key]) {
@@ -1527,6 +1544,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1527
1544
  async function refreshIdleAccount(account) {
1528
1545
  if (!accountManager) return;
1529
1546
  if (idleRefreshInFlight.has(account.id)) return;
1547
+ // CC-sourced accounts don't use OAuth idle refresh
1548
+ if (account.source === "cc-keychain" || account.source === "cc-file") return;
1530
1549
 
1531
1550
  idleRefreshInFlight.add(account.id);
1532
1551
  const attemptedRefreshToken = account.refreshToken;
@@ -1954,6 +1973,32 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1954
1973
  requestInit.body,
1955
1974
  );
1956
1975
  const _reqProvider = detectProvider(requestUrl);
1976
+
1977
+ // --- Adaptive 1M context decision ---
1978
+ const _prevAdaptiveState = adaptiveContextState.active;
1979
+ const _use1MContext = resolveAdaptiveContext(
1980
+ requestInit.body,
1981
+ _reqModel,
1982
+ config.adaptive_context || {
1983
+ enabled: false,
1984
+ escalation_threshold: 150_000,
1985
+ deescalation_threshold: 100_000,
1986
+ },
1987
+ );
1988
+ // Emit visual cue on state transitions (only when adaptive mode is on)
1989
+ if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
1990
+ const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
1991
+ const variant = adaptiveContextState.active ? "info" : "success";
1992
+ const est = estimatePromptTokens(requestInit.body);
1993
+ toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
1994
+ debounceKey: "adaptive-ctx",
1995
+ }).catch(() => {});
1996
+ }
1997
+
1998
+ const _adaptiveOverride = config.adaptive_context?.enabled
1999
+ ? { use1MContext: _use1MContext }
2000
+ : undefined;
2001
+
1957
2002
  const computedBetaHeader = buildAnthropicBetaHeader(
1958
2003
  "",
1959
2004
  getSignatureEmulationEnabled(),
@@ -1963,6 +2008,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1963
2008
  getEffectiveStrategy(),
1964
2009
  requestUrl?.pathname,
1965
2010
  _reqHasFileRefs,
2011
+ _adaptiveOverride,
1966
2012
  );
1967
2013
 
1968
2014
  let body = transformRequestBody(
@@ -1986,12 +2032,20 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1986
2032
  logTransformedSystemPrompt(body);
1987
2033
 
1988
2034
  // Build headers with the selected account's token
1989
- const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
1990
- enabled: getSignatureEmulationEnabled(),
1991
- claudeCliVersion,
1992
- customBetas: config.custom_betas,
1993
- strategy: getEffectiveStrategy(),
1994
- });
2035
+ const requestHeaders = buildRequestHeaders(
2036
+ input,
2037
+ requestInit,
2038
+ accessToken,
2039
+ body,
2040
+ requestUrl,
2041
+ {
2042
+ enabled: getSignatureEmulationEnabled(),
2043
+ claudeCliVersion,
2044
+ customBetas: config.custom_betas,
2045
+ strategy: getEffectiveStrategy(),
2046
+ },
2047
+ _adaptiveOverride,
2048
+ );
1995
2049
  // Execute the request
1996
2050
  let response;
1997
2051
  try {
@@ -2142,6 +2196,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2142
2196
  !requestInit._reactiveCompactAttempted
2143
2197
  ) {
2144
2198
  debugLog("prompt too long — attempting reactive message trimming");
2199
+ // Auto-escalate adaptive context on prompt_too_long so the retry
2200
+ // includes the 1M beta header (if model supports it).
2201
+ if (config.adaptive_context?.enabled) {
2202
+ forceEscalateAdaptiveContext();
2203
+ toast("⬡ 1M context force-activated (prompt too long)", "warning", {
2204
+ debounceKey: "adaptive-ctx",
2205
+ }).catch(() => {});
2206
+ }
2145
2207
  try {
2146
2208
  const parsedBody = JSON.parse(body);
2147
2209
  if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
@@ -2616,6 +2678,111 @@ const sessionMetrics = {
2616
2678
  lastQuota: { tokens: 0, requests: 0, inputTokens: 0, updatedAt: 0 },
2617
2679
  };
2618
2680
 
2681
+ // ---------------------------------------------------------------------------
2682
+ // Adaptive 1M context state
2683
+ // ---------------------------------------------------------------------------
2684
+
2685
+ /**
2686
+ * Tracks whether the 1M context beta is currently active for adaptive mode.
2687
+ * When adaptive_context.enabled is true, the context-1m-2025-08-07 beta is
2688
+ * toggled per-request based on estimated prompt size.
2689
+ *
2690
+ * @type {{ active: boolean, lastTransitionTurn: number, escalatedByError: boolean }}
2691
+ */
2692
+ const adaptiveContextState = {
2693
+ /** Whether 1M context beta is currently being sent. */
2694
+ active: false,
2695
+ /** Turn number of the last transition (to avoid flapping). */
2696
+ lastTransitionTurn: 0,
2697
+ /** Set when escalation was triggered by a prompt_too_long error. */
2698
+ escalatedByError: false,
2699
+ };
2700
+
2701
+ /**
2702
+ * Estimate prompt token count from the raw request body string.
2703
+ * Uses a 4-character-per-token heuristic (conservative for English + code).
2704
+ * @param {string} bodyString - JSON string of the request body
2705
+ * @returns {number} Estimated token count
2706
+ */
2707
+ function estimatePromptTokens(bodyString) {
2708
+ if (!bodyString || typeof bodyString !== "string") return 0;
2709
+ // The body includes tools, system prompt, and messages.
2710
+ // 4 chars/token is a reasonable heuristic for JSON-encoded conversation.
2711
+ return Math.ceil(bodyString.length / 4);
2712
+ }
2713
+
2714
+ /**
2715
+ * Decide whether to include the context-1m beta for this request.
2716
+ * Returns true if 1M context should be activated.
2717
+ *
2718
+ * Decision logic:
2719
+ * - If adaptive_context is disabled, defer to hasOneMillionContext(model) as before.
2720
+ * - If model does not support 1M context, always false.
2721
+ * - Escalate when estimated prompt tokens exceed escalation_threshold.
2722
+ * - De-escalate when estimated prompt tokens drop below deescalation_threshold.
2723
+ * - Never de-escalate if escalation was triggered by a prompt_too_long error
2724
+ * (sticky until session compacts or drops far below threshold).
2725
+ * - Hysteresis: require at least 2 turns between transitions to avoid flapping.
2726
+ *
2727
+ * @param {string} bodyString - JSON request body
2728
+ * @param {string} model - Model ID
2729
+ * @param {import('./lib/config.mjs').AdaptiveContextConfig} adaptiveConfig
2730
+ * @returns {boolean}
2731
+ */
2732
+ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
2733
+ // Non-adaptive: use static check
2734
+ if (!adaptiveConfig.enabled) {
2735
+ return hasOneMillionContext(model);
2736
+ }
2737
+
2738
+ // Model must be eligible for 1M context at all
2739
+ if (!hasOneMillionContext(model)) {
2740
+ return false;
2741
+ }
2742
+
2743
+ const estimatedTokens = estimatePromptTokens(bodyString);
2744
+ const turnsSinceTransition = sessionMetrics.turns - adaptiveContextState.lastTransitionTurn;
2745
+
2746
+ if (adaptiveContextState.active) {
2747
+ // Currently active — consider de-escalation
2748
+ // Don't de-escalate if error-escalated (sticky)
2749
+ if (adaptiveContextState.escalatedByError) {
2750
+ return true;
2751
+ }
2752
+ // Hysteresis: require at least 2 turns before considering de-escalation
2753
+ if (turnsSinceTransition < 2) {
2754
+ return true;
2755
+ }
2756
+ if (estimatedTokens < adaptiveConfig.deescalation_threshold) {
2757
+ // De-escalate
2758
+ adaptiveContextState.active = false;
2759
+ adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
2760
+ return false;
2761
+ }
2762
+ return true;
2763
+ } else {
2764
+ // Currently inactive — consider escalation
2765
+ if (estimatedTokens > adaptiveConfig.escalation_threshold) {
2766
+ // Escalate
2767
+ adaptiveContextState.active = true;
2768
+ adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
2769
+ return true;
2770
+ }
2771
+ return false;
2772
+ }
2773
+ }
2774
+
2775
+ /**
2776
+ * Force-escalate adaptive context (e.g. after prompt_too_long error).
2777
+ */
2778
+ function forceEscalateAdaptiveContext() {
2779
+ if (!adaptiveContextState.active) {
2780
+ adaptiveContextState.active = true;
2781
+ adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
2782
+ }
2783
+ adaptiveContextState.escalatedByError = true;
2784
+ }
2785
+
2619
2786
  const MODEL_PRICING = {
2620
2787
  "claude-opus-4-6": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
2621
2788
  "claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
@@ -3642,6 +3809,7 @@ function buildSystemPromptBlocks(system, signature) {
3642
3809
  * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
3643
3810
  * @param {string} [requestPath]
3644
3811
  * @param {boolean} [hasFileReferences]
3812
+ * @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
3645
3813
  * @returns {string}
3646
3814
  */
3647
3815
  function buildAnthropicBetaHeader(
@@ -3653,6 +3821,7 @@ function buildAnthropicBetaHeader(
3653
3821
  strategy,
3654
3822
  requestPath,
3655
3823
  hasFileReferences,
3824
+ adaptiveOverride,
3656
3825
  ) {
3657
3826
  const incomingBetasList = incomingBeta
3658
3827
  .split(",")
@@ -3694,9 +3863,15 @@ function buildAnthropicBetaHeader(
3694
3863
  betas.push("interleaved-thinking-2025-05-14");
3695
3864
  }
3696
3865
 
3697
- // Context 1M — always-on for eligible models (Claude Code includes this for OAuth too)
3698
- if (hasOneMillionContext(model)) {
3699
- betas.push("context-1m-2025-08-07");
3866
+ // Context 1M — when adaptive override is provided, use it; otherwise fall back to static check.
3867
+ {
3868
+ const use1M =
3869
+ adaptiveOverride && typeof adaptiveOverride.use1MContext === "boolean"
3870
+ ? adaptiveOverride.use1MContext
3871
+ : hasOneMillionContext(model);
3872
+ if (use1M) {
3873
+ betas.push("context-1m-2025-08-07");
3874
+ }
3700
3875
  }
3701
3876
 
3702
3877
  // Prompt caching scope — always-on EXCEPT in round-robin (per-workspace state conflicts)
@@ -3885,7 +4060,7 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
3885
4060
  * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[]}} signature
3886
4061
  * @returns {Headers}
3887
4062
  */
3888
- function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
4063
+ function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature, adaptiveOverride) {
3889
4064
  const requestHeaders = new Headers();
3890
4065
  if (input instanceof Request) {
3891
4066
  input.headers.forEach((value, key) => {
@@ -3925,6 +4100,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
3925
4100
  signature.strategy,
3926
4101
  requestUrl?.pathname,
3927
4102
  hasFileReferences,
4103
+ adaptiveOverride,
3928
4104
  );
3929
4105
 
3930
4106
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
@@ -4568,6 +4744,31 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
4568
4744
  * @throws {Error} If refresh fails
4569
4745
  */
4570
4746
  async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
4747
+ // CC-sourced accounts must NEVER enter the OAuth HTTP refresh flow.
4748
+ // Instead, re-read credentials from the CC source. If they're still
4749
+ // expired, let the caller handle it (the account will be skipped).
4750
+ if (account.source === "cc-keychain" || account.source === "cc-file") {
4751
+ const { readCCCredentials } = await import("./lib/cc-credentials.mjs");
4752
+ const ccCreds = readCCCredentials();
4753
+ const match = ccCreds.find((c) => c.refreshToken === account.refreshToken);
4754
+ if (match && match.expiresAt > Date.now()) {
4755
+ account.access = match.accessToken;
4756
+ account.expires = match.expiresAt;
4757
+ markTokenStateUpdated(account);
4758
+ if (onTokensUpdated) {
4759
+ try {
4760
+ await onTokensUpdated();
4761
+ } catch {
4762
+ // best-effort
4763
+ }
4764
+ }
4765
+ return account.access;
4766
+ }
4767
+ // Could not refresh from CC source – token may be stale.
4768
+ // Throw so the caller falls through to the next account.
4769
+ throw new Error(`CC credential expired (source: ${account.source})`);
4770
+ }
4771
+
4571
4772
  const lockResult = await acquireRefreshLock(account.id, {
4572
4773
  timeoutMs: 2_000,
4573
4774
  backoffMs: 60,
package/lib/accounts.mjs CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
3
3
  import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
4
4
  import { calculateBackoffMs } from "./backoff.mjs";
5
+ import { readCCCredentials } from "./cc-credentials.mjs";
5
6
 
6
7
  /** QA fix M12: hash token fragment instead of leaking raw prefix */
7
8
  function hashTokenFragment(token) {
@@ -20,6 +21,10 @@ function hashTokenFragment(token) {
20
21
  * @typedef {import('./storage.mjs').AccountStats} AccountStats
21
22
  */
22
23
 
24
+ /**
25
+ * @typedef {'oauth' | 'cc-keychain' | 'cc-file'} AccountSource
26
+ */
27
+
23
28
  /**
24
29
  * @typedef {object} ManagedAccount
25
30
  * @property {string} id
@@ -37,6 +42,7 @@ function hashTokenFragment(token) {
37
42
  * @property {number | null} lastFailureTime
38
43
  * @property {string} [lastSwitchReason]
39
44
  * @property {AccountStats} stats
45
+ * @property {AccountSource} [source] - Origin of the account. CC accounts must not enter OAuth HTTP refresh.
40
46
  */
41
47
 
42
48
  const MAX_ACCOUNTS = 10;
@@ -113,6 +119,7 @@ export class AccountManager {
113
119
  lastFailureTime: acc.lastFailureTime,
114
120
  lastSwitchReason: acc.lastSwitchReason,
115
121
  stats: acc.stats ?? createDefaultStats(acc.addedAt),
122
+ source: acc.source || "oauth",
116
123
  }));
117
124
 
118
125
  manager.#currentIndex =
@@ -136,6 +143,9 @@ export class AccountManager {
136
143
  }
137
144
  }
138
145
 
146
+ // Merge CC credentials if enabled
147
+ manager.#mergeCC(config);
148
+
139
149
  return manager;
140
150
  }
141
151
 
@@ -159,14 +169,95 @@ export class AccountManager {
159
169
  lastFailureTime: null,
160
170
  lastSwitchReason: "initial",
161
171
  stats: createDefaultStats(now),
172
+ source: "oauth",
162
173
  },
163
174
  ];
164
175
  manager.#currentIndex = 0;
165
176
  }
166
177
 
178
+ // Merge CC credentials if enabled
179
+ manager.#mergeCC(config);
180
+
167
181
  return manager;
168
182
  }
169
183
 
184
+ /**
185
+ * Merge Claude Code credentials into the account pool.
186
+ * CC accounts are matched by refreshToken to avoid duplicates.
187
+ *
188
+ * @param {AnthropicAuthConfig} config
189
+ */
190
+ #mergeCC(config) {
191
+ if (!config.cc_credential_reuse?.enabled || !config.cc_credential_reuse?.auto_detect) return;
192
+
193
+ /** @type {import('./cc-credentials.mjs').CCCredential[]} */
194
+ let ccCreds;
195
+ try {
196
+ ccCreds = readCCCredentials();
197
+ } catch {
198
+ return; // best-effort
199
+ }
200
+
201
+ if (!ccCreds.length) return;
202
+
203
+ for (const cc of ccCreds) {
204
+ // Skip if refresh token already known
205
+ const existing = this.#accounts.find((a) => a.refreshToken === cc.refreshToken);
206
+ if (existing) {
207
+ // Update access token if CC's is fresher
208
+ if (cc.expiresAt > (existing.expires || 0)) {
209
+ existing.access = cc.accessToken;
210
+ existing.expires = cc.expiresAt;
211
+ existing.tokenUpdatedAt = Date.now();
212
+ }
213
+ // Ensure source is marked
214
+ if (!existing.source || existing.source === "oauth") {
215
+ existing.source = cc.source;
216
+ }
217
+ continue;
218
+ }
219
+
220
+ if (this.#accounts.length >= MAX_ACCOUNTS) break;
221
+
222
+ const now = Date.now();
223
+ /** @type {ManagedAccount} */
224
+ const account = {
225
+ id: `cc:${now}:${hashTokenFragment(cc.refreshToken)}`,
226
+ index: this.#accounts.length,
227
+ email: undefined,
228
+ refreshToken: cc.refreshToken,
229
+ access: cc.accessToken,
230
+ expires: cc.expiresAt,
231
+ tokenUpdatedAt: now,
232
+ addedAt: now,
233
+ lastUsed: 0,
234
+ enabled: true,
235
+ rateLimitResetTimes: {},
236
+ consecutiveFailures: 0,
237
+ lastFailureTime: null,
238
+ lastSwitchReason: "cc-auto-detect",
239
+ stats: createDefaultStats(now),
240
+ source: cc.source,
241
+ };
242
+
243
+ if (config.cc_credential_reuse.prefer_over_oauth) {
244
+ this.#accounts.unshift(account);
245
+ // Re-index after unshift
246
+ this.#accounts.forEach((a, i) => {
247
+ a.index = i;
248
+ });
249
+ if (this.#currentIndex >= 0) this.#currentIndex++;
250
+ } else {
251
+ this.#accounts.push(account);
252
+ }
253
+
254
+ // If this is the first account, make it active
255
+ if (this.#accounts.length === 1) {
256
+ this.#currentIndex = 0;
257
+ }
258
+ }
259
+ }
260
+
170
261
  /**
171
262
  * Get the number of enabled accounts.
172
263
  * @returns {number}
@@ -642,6 +733,7 @@ export class AccountManager {
642
733
  lastFailureTime: acc.lastFailureTime,
643
734
  lastSwitchReason: acc.lastSwitchReason,
644
735
  stats: mergedStats,
736
+ source: acc.source,
645
737
  };
646
738
  }),
647
739
  activeIndex: Math.max(0, this.#currentIndex),
@@ -0,0 +1,253 @@
1
+ /**
2
+ * CC (Claude Code) credential reader.
3
+ *
4
+ * Reads OAuth tokens that Claude Code has already obtained and stored,
5
+ * allowing opencode-anthropic-fix to piggy-back on those tokens instead
6
+ * of maintaining its own OAuth flow.
7
+ *
8
+ * Two sources are supported:
9
+ * 1. macOS Keychain – `security find-generic-password` for services
10
+ * matching "Claude Code-credentials*"
11
+ * 2. File-based – `~/.claude/.credentials.json`
12
+ *
13
+ * CC accounts MUST NEVER enter the normal OAuth HTTP token-refresh path
14
+ * because the client_id would mismatch and corrupt CC's stored tokens.
15
+ *
16
+ * @module cc-credentials
17
+ */
18
+
19
+ import { execSync } from "node:child_process";
20
+ import { readFileSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ /**
25
+ * @typedef {'cc-keychain' | 'cc-file'} CCCredentialSource
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} CCCredential
30
+ * @property {string} accessToken
31
+ * @property {string} refreshToken
32
+ * @property {number} expiresAt - ms-epoch
33
+ * @property {string} [subscriptionType] - e.g. "claude_pro"
34
+ * @property {CCCredentialSource} source
35
+ * @property {string} label - human-readable label for logging/toasts
36
+ */
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Keychain reader (macOS only)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * List all keychain services matching the CC credential pattern.
44
+ * Returns an array of service names (e.g. "Claude Code-credentials-0").
45
+ *
46
+ * @returns {string[]}
47
+ */
48
+ function listKeychainServices() {
49
+ if (process.platform !== "darwin") return [];
50
+
51
+ try {
52
+ const dump = execSync("security dump-keychain", {
53
+ timeout: 5_000,
54
+ encoding: "utf-8",
55
+ stdio: ["ignore", "pipe", "ignore"],
56
+ });
57
+
58
+ /** @type {string[]} */
59
+ const services = [];
60
+ const regex = /"svce"<blob>="(Claude Code-credentials[^"]*)"/g;
61
+ let match;
62
+ while ((match = regex.exec(dump)) !== null) {
63
+ const svc = match[1];
64
+ if (!services.includes(svc)) services.push(svc);
65
+ }
66
+ return services;
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Read a single CC credential from macOS Keychain by service name.
74
+ *
75
+ * @param {string} service
76
+ * @returns {CCCredential | null}
77
+ */
78
+ function readKeychainService(service) {
79
+ try {
80
+ const raw = execSync(`security find-generic-password -s "${service}" -w`, {
81
+ timeout: 5_000,
82
+ encoding: "utf-8",
83
+ stdio: ["ignore", "pipe", "ignore"],
84
+ }).trim();
85
+
86
+ return parseCCCredentialData(raw, "cc-keychain", `keychain:${service}`);
87
+ } catch (err) {
88
+ // Exit code 36 = user denied, 44 = not found, 128 = killed
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Read all CC credentials stored in the macOS Keychain.
95
+ *
96
+ * @returns {CCCredential[]}
97
+ */
98
+ export function readCCCredentialsFromKeychain() {
99
+ const services = listKeychainServices();
100
+ /** @type {CCCredential[]} */
101
+ const credentials = [];
102
+
103
+ for (const svc of services) {
104
+ const cred = readKeychainService(svc);
105
+ if (cred) credentials.push(cred);
106
+ }
107
+
108
+ return credentials;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // File reader (~/.claude/.credentials.json)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Read CC credentials from the file-based store.
117
+ *
118
+ * The file can have two shapes:
119
+ * - Wrapped: `{ claudeAiOauth: { accessToken, refreshToken, expiresAt, ... } }`
120
+ * - Flat: `{ accessToken, refreshToken, expiresAt, ... }`
121
+ *
122
+ * Additionally it may be an array of such objects.
123
+ *
124
+ * Entries with `scope: "mcp"` or missing accessToken/refreshToken are filtered.
125
+ *
126
+ * @returns {CCCredential[]}
127
+ */
128
+ export function readCCCredentialsFromFile() {
129
+ const credPath = join(homedir(), ".claude", ".credentials.json");
130
+
131
+ if (!existsSync(credPath)) return [];
132
+
133
+ try {
134
+ const raw = readFileSync(credPath, "utf-8");
135
+ const parsed = JSON.parse(raw);
136
+
137
+ /** @type {CCCredential[]} */
138
+ const results = [];
139
+
140
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
141
+
142
+ for (let i = 0; i < entries.length; i++) {
143
+ const entry = entries[i];
144
+ if (!entry || typeof entry !== "object") continue;
145
+
146
+ // Unwrap "claudeAiOauth" wrapper if present
147
+ const data = entry.claudeAiOauth && typeof entry.claudeAiOauth === "object" ? entry.claudeAiOauth : entry;
148
+
149
+ // Skip MCP-only entries
150
+ if (data.scope === "mcp") continue;
151
+
152
+ const cred = shapeToCCCredential(data, "cc-file", `file:entry-${i}`);
153
+ if (cred) results.push(cred);
154
+ }
155
+
156
+ return results;
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Unified reader
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Read all discoverable CC credentials from all sources.
168
+ *
169
+ * @returns {CCCredential[]}
170
+ */
171
+ export function readCCCredentials() {
172
+ /** @type {CCCredential[]} */
173
+ const all = [];
174
+
175
+ // Keychain (macOS only)
176
+ all.push(...readCCCredentialsFromKeychain());
177
+
178
+ // File-based (cross-platform)
179
+ all.push(...readCCCredentialsFromFile());
180
+
181
+ // De-duplicate by refreshToken (prefer keychain over file)
182
+ /** @type {Map<string, CCCredential>} */
183
+ const seen = new Map();
184
+ for (const cred of all) {
185
+ const existing = seen.get(cred.refreshToken);
186
+ if (!existing || (cred.source === "cc-keychain" && existing.source === "cc-file")) {
187
+ seen.set(cred.refreshToken, cred);
188
+ }
189
+ }
190
+
191
+ return [...seen.values()];
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Parsing helpers
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /**
199
+ * Parse a raw JSON string into a CCCredential.
200
+ *
201
+ * @param {string} jsonStr
202
+ * @param {CCCredentialSource} source
203
+ * @param {string} label
204
+ * @returns {CCCredential | null}
205
+ */
206
+ export function parseCCCredentialData(jsonStr, source, label) {
207
+ try {
208
+ const data = JSON.parse(jsonStr);
209
+ if (!data || typeof data !== "object") return null;
210
+
211
+ // Handle wrapped format
212
+ const inner = data.claudeAiOauth && typeof data.claudeAiOauth === "object" ? data.claudeAiOauth : data;
213
+
214
+ return shapeToCCCredential(inner, source, label);
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Validate and shape an object into a CCCredential.
222
+ *
223
+ * @param {Record<string, unknown>} data
224
+ * @param {CCCredentialSource} source
225
+ * @param {string} label
226
+ * @returns {CCCredential | null}
227
+ */
228
+ function shapeToCCCredential(data, source, label) {
229
+ const accessToken = typeof data.accessToken === "string" ? data.accessToken : null;
230
+ const refreshToken = typeof data.refreshToken === "string" ? data.refreshToken : null;
231
+
232
+ if (!accessToken || !refreshToken) return null;
233
+
234
+ // expiresAt can be number (ms-epoch) or ISO string
235
+ let expiresAt = 0;
236
+ if (typeof data.expiresAt === "number") {
237
+ expiresAt = data.expiresAt;
238
+ } else if (typeof data.expiresAt === "string") {
239
+ const parsed = Date.parse(data.expiresAt);
240
+ if (!Number.isNaN(parsed)) expiresAt = parsed;
241
+ } else if (typeof data.expires_at === "number") {
242
+ expiresAt = data.expires_at;
243
+ }
244
+
245
+ return {
246
+ accessToken,
247
+ refreshToken,
248
+ expiresAt,
249
+ subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
250
+ source,
251
+ label,
252
+ };
253
+ }
@@ -0,0 +1,377 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ readCCCredentialsFromKeychain,
4
+ readCCCredentialsFromFile,
5
+ readCCCredentials,
6
+ parseCCCredentialData,
7
+ } from "./cc-credentials.mjs";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Mocks
11
+ // ---------------------------------------------------------------------------
12
+
13
+ vi.mock("node:child_process", () => ({
14
+ execSync: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("node:fs", () => ({
18
+ readFileSync: vi.fn(),
19
+ existsSync: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("node:os", () => ({
23
+ homedir: vi.fn(() => "/home/testuser"),
24
+ }));
25
+
26
+ import { execSync } from "node:child_process";
27
+ import { readFileSync, existsSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Fixtures
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const VALID_CRED = {
35
+ accessToken: "acc-123",
36
+ refreshToken: "ref-456",
37
+ expiresAt: Date.now() + 3_600_000,
38
+ subscriptionType: "claude_pro",
39
+ };
40
+
41
+ const VALID_CRED_2 = {
42
+ accessToken: "acc-789",
43
+ refreshToken: "ref-012",
44
+ expiresAt: Date.now() + 7_200_000,
45
+ };
46
+
47
+ const MCP_ONLY_CRED = {
48
+ accessToken: "mcp-acc",
49
+ refreshToken: "mcp-ref",
50
+ expiresAt: Date.now() + 3_600_000,
51
+ scope: "mcp",
52
+ };
53
+
54
+ const KEYCHAIN_DUMP_SINGLE = `
55
+ keychain: "/Users/test/Library/Keychains/login.keychain-db"
56
+ 0x00000007 <blob>="Claude Code-credentials"
57
+ "svce"<blob>="Claude Code-credentials"
58
+ `;
59
+
60
+ const KEYCHAIN_DUMP_MULTI = `
61
+ keychain: "/Users/test/Library/Keychains/login.keychain-db"
62
+ "svce"<blob>="Claude Code-credentials-0"
63
+ "svce"<blob>="Claude Code-credentials-1"
64
+ "svce"<blob>="Claude Code-credentials-0"
65
+ `;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // parseCCCredentialData
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe("parseCCCredentialData", () => {
72
+ it("parses valid flat JSON", () => {
73
+ const result = parseCCCredentialData(JSON.stringify(VALID_CRED), "cc-file", "test");
74
+ expect(result).not.toBeNull();
75
+ expect(result.accessToken).toBe("acc-123");
76
+ expect(result.refreshToken).toBe("ref-456");
77
+ expect(result.subscriptionType).toBe("claude_pro");
78
+ expect(result.source).toBe("cc-file");
79
+ expect(result.label).toBe("test");
80
+ });
81
+
82
+ it("parses wrapped claudeAiOauth format", () => {
83
+ const wrapped = { claudeAiOauth: VALID_CRED };
84
+ const result = parseCCCredentialData(JSON.stringify(wrapped), "cc-keychain", "kc");
85
+ expect(result).not.toBeNull();
86
+ expect(result.accessToken).toBe("acc-123");
87
+ expect(result.source).toBe("cc-keychain");
88
+ });
89
+
90
+ it("returns null for invalid JSON", () => {
91
+ expect(parseCCCredentialData("not json", "cc-file", "x")).toBeNull();
92
+ });
93
+
94
+ it("returns null for missing accessToken", () => {
95
+ const data = { refreshToken: "ref" };
96
+ expect(parseCCCredentialData(JSON.stringify(data), "cc-file", "x")).toBeNull();
97
+ });
98
+
99
+ it("returns null for missing refreshToken", () => {
100
+ const data = { accessToken: "acc" };
101
+ expect(parseCCCredentialData(JSON.stringify(data), "cc-file", "x")).toBeNull();
102
+ });
103
+
104
+ it("returns null for non-object JSON", () => {
105
+ expect(parseCCCredentialData('"hello"', "cc-file", "x")).toBeNull();
106
+ expect(parseCCCredentialData("42", "cc-file", "x")).toBeNull();
107
+ expect(parseCCCredentialData("null", "cc-file", "x")).toBeNull();
108
+ });
109
+
110
+ it("handles expiresAt as ISO string", () => {
111
+ const data = { ...VALID_CRED, expiresAt: "2026-06-15T12:00:00Z" };
112
+ const result = parseCCCredentialData(JSON.stringify(data), "cc-file", "x");
113
+ expect(result).not.toBeNull();
114
+ expect(result.expiresAt).toBe(Date.parse("2026-06-15T12:00:00Z"));
115
+ });
116
+
117
+ it("handles expires_at fallback field", () => {
118
+ const data = { accessToken: "a", refreshToken: "r", expires_at: 999999 };
119
+ const result = parseCCCredentialData(JSON.stringify(data), "cc-file", "x");
120
+ expect(result).not.toBeNull();
121
+ expect(result.expiresAt).toBe(999999);
122
+ });
123
+
124
+ it("defaults expiresAt to 0 when missing", () => {
125
+ const data = { accessToken: "a", refreshToken: "r" };
126
+ const result = parseCCCredentialData(JSON.stringify(data), "cc-file", "x");
127
+ expect(result.expiresAt).toBe(0);
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // readCCCredentialsFromKeychain
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe("readCCCredentialsFromKeychain", () => {
136
+ const origPlatform = process.platform;
137
+
138
+ afterEach(() => {
139
+ Object.defineProperty(process, "platform", { value: origPlatform });
140
+ vi.resetAllMocks();
141
+ });
142
+
143
+ it("returns empty on non-darwin platforms", () => {
144
+ Object.defineProperty(process, "platform", { value: "linux" });
145
+ expect(readCCCredentialsFromKeychain()).toEqual([]);
146
+ expect(execSync).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("reads single keychain service on macOS", () => {
150
+ Object.defineProperty(process, "platform", { value: "darwin" });
151
+
152
+ execSync.mockImplementation((cmd) => {
153
+ if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
154
+ if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
155
+ throw new Error("unexpected cmd");
156
+ });
157
+
158
+ const result = readCCCredentialsFromKeychain();
159
+ expect(result).toHaveLength(1);
160
+ expect(result[0].accessToken).toBe("acc-123");
161
+ expect(result[0].source).toBe("cc-keychain");
162
+ expect(result[0].label).toContain("keychain:");
163
+ });
164
+
165
+ it("reads multiple keychain services and deduplicates service names", () => {
166
+ Object.defineProperty(process, "platform", { value: "darwin" });
167
+
168
+ let callIdx = 0;
169
+ execSync.mockImplementation((cmd) => {
170
+ if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_MULTI;
171
+ if (cmd.includes("find-generic-password")) {
172
+ callIdx++;
173
+ const cred = callIdx === 1 ? VALID_CRED : VALID_CRED_2;
174
+ return JSON.stringify(cred);
175
+ }
176
+ throw new Error("unexpected cmd");
177
+ });
178
+
179
+ const result = readCCCredentialsFromKeychain();
180
+ // KEYCHAIN_DUMP_MULTI has credentials-0 twice, but should be deduped to 2 services
181
+ expect(result).toHaveLength(2);
182
+ expect(result[0].accessToken).toBe("acc-123");
183
+ expect(result[1].accessToken).toBe("acc-789");
184
+ });
185
+
186
+ it("returns empty when dump-keychain throws", () => {
187
+ Object.defineProperty(process, "platform", { value: "darwin" });
188
+ execSync.mockImplementation(() => {
189
+ throw new Error("no keychain");
190
+ });
191
+
192
+ expect(readCCCredentialsFromKeychain()).toEqual([]);
193
+ });
194
+
195
+ it("skips services where find-generic-password fails (exit 44)", () => {
196
+ Object.defineProperty(process, "platform", { value: "darwin" });
197
+
198
+ execSync.mockImplementation((cmd) => {
199
+ if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
200
+ if (cmd.includes("find-generic-password")) {
201
+ const err = new Error("not found");
202
+ err.status = 44;
203
+ throw err;
204
+ }
205
+ throw new Error("unexpected");
206
+ });
207
+
208
+ expect(readCCCredentialsFromKeychain()).toEqual([]);
209
+ });
210
+ });
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // readCCCredentialsFromFile
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe("readCCCredentialsFromFile", () => {
217
+ beforeEach(() => {
218
+ vi.resetAllMocks();
219
+ homedir.mockReturnValue("/home/testuser");
220
+ });
221
+
222
+ it("returns empty when credentials file does not exist", () => {
223
+ existsSync.mockReturnValue(false);
224
+ expect(readCCCredentialsFromFile()).toEqual([]);
225
+ });
226
+
227
+ it("reads flat credential object", () => {
228
+ existsSync.mockReturnValue(true);
229
+ readFileSync.mockReturnValue(JSON.stringify(VALID_CRED));
230
+
231
+ const result = readCCCredentialsFromFile();
232
+ expect(result).toHaveLength(1);
233
+ expect(result[0].accessToken).toBe("acc-123");
234
+ expect(result[0].source).toBe("cc-file");
235
+ expect(result[0].label).toBe("file:entry-0");
236
+ });
237
+
238
+ it("reads wrapped claudeAiOauth format", () => {
239
+ existsSync.mockReturnValue(true);
240
+ readFileSync.mockReturnValue(JSON.stringify({ claudeAiOauth: VALID_CRED }));
241
+
242
+ const result = readCCCredentialsFromFile();
243
+ expect(result).toHaveLength(1);
244
+ expect(result[0].accessToken).toBe("acc-123");
245
+ });
246
+
247
+ it("reads array of credentials", () => {
248
+ existsSync.mockReturnValue(true);
249
+ readFileSync.mockReturnValue(JSON.stringify([VALID_CRED, VALID_CRED_2]));
250
+
251
+ const result = readCCCredentialsFromFile();
252
+ expect(result).toHaveLength(2);
253
+ expect(result[0].accessToken).toBe("acc-123");
254
+ expect(result[1].accessToken).toBe("acc-789");
255
+ });
256
+
257
+ it("filters MCP-only entries", () => {
258
+ existsSync.mockReturnValue(true);
259
+ readFileSync.mockReturnValue(JSON.stringify([VALID_CRED, MCP_ONLY_CRED]));
260
+
261
+ const result = readCCCredentialsFromFile();
262
+ expect(result).toHaveLength(1);
263
+ expect(result[0].accessToken).toBe("acc-123");
264
+ });
265
+
266
+ it("skips non-object entries in array", () => {
267
+ existsSync.mockReturnValue(true);
268
+ readFileSync.mockReturnValue(JSON.stringify([null, 42, "hello", VALID_CRED]));
269
+
270
+ const result = readCCCredentialsFromFile();
271
+ expect(result).toHaveLength(1);
272
+ expect(result[0].accessToken).toBe("acc-123");
273
+ });
274
+
275
+ it("returns empty on invalid JSON", () => {
276
+ existsSync.mockReturnValue(true);
277
+ readFileSync.mockReturnValue("not json at all");
278
+
279
+ expect(readCCCredentialsFromFile()).toEqual([]);
280
+ });
281
+
282
+ it("returns empty on read error", () => {
283
+ existsSync.mockReturnValue(true);
284
+ readFileSync.mockImplementation(() => {
285
+ throw new Error("EACCES");
286
+ });
287
+
288
+ expect(readCCCredentialsFromFile()).toEqual([]);
289
+ });
290
+
291
+ it("handles entries missing required fields gracefully", () => {
292
+ existsSync.mockReturnValue(true);
293
+ readFileSync.mockReturnValue(JSON.stringify([{ accessToken: "only-access" }, VALID_CRED]));
294
+
295
+ const result = readCCCredentialsFromFile();
296
+ expect(result).toHaveLength(1);
297
+ expect(result[0].accessToken).toBe("acc-123");
298
+ });
299
+ });
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // readCCCredentials (unified)
303
+ // ---------------------------------------------------------------------------
304
+
305
+ describe("readCCCredentials", () => {
306
+ const origPlatform = process.platform;
307
+
308
+ beforeEach(() => {
309
+ vi.resetAllMocks();
310
+ homedir.mockReturnValue("/home/testuser");
311
+ });
312
+
313
+ afterEach(() => {
314
+ Object.defineProperty(process, "platform", { value: origPlatform });
315
+ });
316
+
317
+ it("combines keychain and file sources on macOS", () => {
318
+ Object.defineProperty(process, "platform", { value: "darwin" });
319
+
320
+ // Keychain returns one credential
321
+ execSync.mockImplementation((cmd) => {
322
+ if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
323
+ if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
324
+ throw new Error("unexpected");
325
+ });
326
+
327
+ // File returns a different credential
328
+ existsSync.mockReturnValue(true);
329
+ readFileSync.mockReturnValue(JSON.stringify(VALID_CRED_2));
330
+
331
+ const result = readCCCredentials();
332
+ expect(result).toHaveLength(2);
333
+ expect(result.some((c) => c.source === "cc-keychain")).toBe(true);
334
+ expect(result.some((c) => c.source === "cc-file")).toBe(true);
335
+ });
336
+
337
+ it("deduplicates by refreshToken, preferring keychain", () => {
338
+ Object.defineProperty(process, "platform", { value: "darwin" });
339
+
340
+ // Both sources return same refreshToken
341
+ const keychainCred = { ...VALID_CRED, accessToken: "keychain-token" };
342
+ const fileCred = { ...VALID_CRED, accessToken: "file-token" };
343
+
344
+ execSync.mockImplementation((cmd) => {
345
+ if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
346
+ if (cmd.includes("find-generic-password")) return JSON.stringify(keychainCred);
347
+ throw new Error("unexpected");
348
+ });
349
+
350
+ existsSync.mockReturnValue(true);
351
+ readFileSync.mockReturnValue(JSON.stringify(fileCred));
352
+
353
+ const result = readCCCredentials();
354
+ expect(result).toHaveLength(1);
355
+ expect(result[0].source).toBe("cc-keychain");
356
+ expect(result[0].accessToken).toBe("keychain-token");
357
+ });
358
+
359
+ it("returns only file credentials on Linux", () => {
360
+ Object.defineProperty(process, "platform", { value: "linux" });
361
+
362
+ existsSync.mockReturnValue(true);
363
+ readFileSync.mockReturnValue(JSON.stringify(VALID_CRED));
364
+
365
+ const result = readCCCredentials();
366
+ expect(result).toHaveLength(1);
367
+ expect(result[0].source).toBe("cc-file");
368
+ expect(execSync).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it("returns empty when no sources available", () => {
372
+ Object.defineProperty(process, "platform", { value: "linux" });
373
+ existsSync.mockReturnValue(false);
374
+
375
+ expect(readCCCredentials()).toEqual([]);
376
+ });
377
+ });
package/lib/config.mjs CHANGED
@@ -53,6 +53,20 @@ import { randomBytes } from "node:crypto";
53
53
  * @property {number} hit_rate_warning_threshold - Warn when avg cache hit rate falls below this (0-1). Default: 0.3
54
54
  */
55
55
 
56
+ /**
57
+ * @typedef {object} AdaptiveContextConfig
58
+ * @property {boolean} enabled - When true, context-1m beta is toggled per-request based on prompt size. Default: false.
59
+ * @property {number} escalation_threshold - Token count (input+cache) above which 1M context is activated. Default: 150_000.
60
+ * @property {number} deescalation_threshold - Token count below which 1M context is deactivated. Default: 100_000.
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} CCCredentialReuseConfig
65
+ * @property {boolean} enabled - Master switch for CC credential reuse. Default: false.
66
+ * @property {boolean} auto_detect - Auto-discover CC credentials on startup. Default: true.
67
+ * @property {boolean} prefer_over_oauth - When true, CC credentials are tried before plugin-managed OAuth accounts. Default: false.
68
+ */
69
+
56
70
  /**
57
71
  * @typedef {object} AnthropicAuthConfig
58
72
  * @property {AccountSelectionStrategy} account_selection_strategy
@@ -70,6 +84,8 @@ import { randomBytes } from "node:crypto";
70
84
  * @property {boolean} fast_mode
71
85
  * @property {{emulate_minimal: boolean}} telemetry
72
86
  * @property {boolean} usage_toast
87
+ * @property {CCCredentialReuseConfig} cc_credential_reuse
88
+ * @property {AdaptiveContextConfig} adaptive_context
73
89
  */
74
90
 
75
91
  /** @type {AnthropicAuthConfig} */
@@ -128,6 +144,16 @@ export const DEFAULT_CONFIG = {
128
144
  disable: [],
129
145
  billing_header: true,
130
146
  },
147
+ cc_credential_reuse: {
148
+ enabled: false,
149
+ auto_detect: true,
150
+ prefer_over_oauth: false,
151
+ },
152
+ adaptive_context: {
153
+ enabled: false,
154
+ escalation_threshold: 150_000,
155
+ deescalation_threshold: 100_000,
156
+ },
131
157
  };
132
158
 
133
159
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
@@ -153,6 +179,8 @@ function createDefaultConfig() {
153
179
  fast_mode: DEFAULT_CONFIG.fast_mode,
154
180
  telemetry: { ...DEFAULT_CONFIG.telemetry },
155
181
  headers: { ...DEFAULT_CONFIG.headers, overrides: {}, disable: [] },
182
+ cc_credential_reuse: { ...DEFAULT_CONFIG.cc_credential_reuse },
183
+ adaptive_context: { ...DEFAULT_CONFIG.adaptive_context },
156
184
  };
157
185
  }
158
186
 
@@ -357,6 +385,40 @@ function validateConfig(raw) {
357
385
  // Usage toast
358
386
  config.usage_toast = typeof raw.usage_toast === "boolean" ? raw.usage_toast : DEFAULT_CONFIG.usage_toast;
359
387
 
388
+ // CC credential reuse sub-config
389
+ if (raw.cc_credential_reuse && typeof raw.cc_credential_reuse === "object") {
390
+ const cc = /** @type {Record<string, unknown>} */ (raw.cc_credential_reuse);
391
+ config.cc_credential_reuse = {
392
+ enabled: typeof cc.enabled === "boolean" ? cc.enabled : DEFAULT_CONFIG.cc_credential_reuse.enabled,
393
+ auto_detect:
394
+ typeof cc.auto_detect === "boolean" ? cc.auto_detect : DEFAULT_CONFIG.cc_credential_reuse.auto_detect,
395
+ prefer_over_oauth:
396
+ typeof cc.prefer_over_oauth === "boolean"
397
+ ? cc.prefer_over_oauth
398
+ : DEFAULT_CONFIG.cc_credential_reuse.prefer_over_oauth,
399
+ };
400
+ }
401
+
402
+ // Adaptive context sub-config
403
+ if (raw.adaptive_context && typeof raw.adaptive_context === "object") {
404
+ const ac = /** @type {Record<string, unknown>} */ (raw.adaptive_context);
405
+ config.adaptive_context = {
406
+ enabled: typeof ac.enabled === "boolean" ? ac.enabled : DEFAULT_CONFIG.adaptive_context.enabled,
407
+ escalation_threshold: clampNumber(
408
+ ac.escalation_threshold,
409
+ 50_000,
410
+ 500_000,
411
+ DEFAULT_CONFIG.adaptive_context.escalation_threshold,
412
+ ),
413
+ deescalation_threshold: clampNumber(
414
+ ac.deescalation_threshold,
415
+ 20_000,
416
+ 400_000,
417
+ DEFAULT_CONFIG.adaptive_context.deescalation_threshold,
418
+ ),
419
+ };
420
+ }
421
+
360
422
  return config;
361
423
  }
362
424
 
@@ -429,6 +491,20 @@ function applyEnvOverrides(config) {
429
491
  config.override_model_limits.enabled = false;
430
492
  }
431
493
 
494
+ if (env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "1" || env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "true") {
495
+ config.cc_credential_reuse.enabled = true;
496
+ }
497
+ if (env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "0" || env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "false") {
498
+ config.cc_credential_reuse.enabled = false;
499
+ }
500
+
501
+ if (env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "1" || env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "true") {
502
+ config.adaptive_context.enabled = true;
503
+ }
504
+ if (env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "0" || env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "false") {
505
+ config.adaptive_context.enabled = false;
506
+ }
507
+
432
508
  return config;
433
509
  }
434
510
 
@@ -70,6 +70,7 @@ describe("loadConfig", () => {
70
70
  delete process.env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE;
71
71
  delete process.env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION;
72
72
  delete process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION;
73
+ delete process.env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT;
73
74
  });
74
75
 
75
76
  afterEach(() => {
@@ -304,4 +305,77 @@ describe("loadConfig", () => {
304
305
  const config = loadConfig();
305
306
  expect(config.toasts.quiet).toBe(false);
306
307
  });
308
+
309
+ // --- adaptive_context config tests ---
310
+
311
+ it("has adaptive_context defaults (disabled)", () => {
312
+ existsSync.mockReturnValue(false);
313
+ const config = loadConfig();
314
+ expect(config.adaptive_context).toEqual({
315
+ enabled: false,
316
+ escalation_threshold: 150_000,
317
+ deescalation_threshold: 100_000,
318
+ });
319
+ });
320
+
321
+ it("merges adaptive_context from config file", () => {
322
+ existsSync.mockReturnValue(true);
323
+ readFileSync.mockReturnValue(
324
+ JSON.stringify({ adaptive_context: { enabled: true, escalation_threshold: 200_000 } }),
325
+ );
326
+ const config = loadConfig();
327
+ expect(config.adaptive_context.enabled).toBe(true);
328
+ expect(config.adaptive_context.escalation_threshold).toBe(200_000);
329
+ expect(config.adaptive_context.deescalation_threshold).toBe(100_000); // default preserved
330
+ });
331
+
332
+ it("clamps adaptive_context thresholds to valid ranges", () => {
333
+ existsSync.mockReturnValue(true);
334
+ readFileSync.mockReturnValue(
335
+ JSON.stringify({
336
+ adaptive_context: {
337
+ enabled: true,
338
+ escalation_threshold: 999_999, // above 500K max → clamped
339
+ deescalation_threshold: 1, // below 20K min → clamped
340
+ },
341
+ }),
342
+ );
343
+ const config = loadConfig();
344
+ expect(config.adaptive_context.escalation_threshold).toBe(500_000);
345
+ expect(config.adaptive_context.deescalation_threshold).toBe(20_000);
346
+ });
347
+
348
+ it("ignores non-boolean adaptive_context.enabled", () => {
349
+ existsSync.mockReturnValue(true);
350
+ readFileSync.mockReturnValue(JSON.stringify({ adaptive_context: { enabled: "yes" } }));
351
+ const config = loadConfig();
352
+ expect(config.adaptive_context.enabled).toBe(false);
353
+ });
354
+
355
+ it("ignores non-number adaptive_context thresholds", () => {
356
+ existsSync.mockReturnValue(true);
357
+ readFileSync.mockReturnValue(
358
+ JSON.stringify({
359
+ adaptive_context: { escalation_threshold: "high", deescalation_threshold: null },
360
+ }),
361
+ );
362
+ const config = loadConfig();
363
+ expect(config.adaptive_context.escalation_threshold).toBe(150_000);
364
+ expect(config.adaptive_context.deescalation_threshold).toBe(100_000);
365
+ });
366
+
367
+ it("enables adaptive context from OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT=1", () => {
368
+ existsSync.mockReturnValue(false);
369
+ process.env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT = "1";
370
+ const config = loadConfig();
371
+ expect(config.adaptive_context.enabled).toBe(true);
372
+ });
373
+
374
+ it("disables adaptive context from OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT=0", () => {
375
+ existsSync.mockReturnValue(true);
376
+ readFileSync.mockReturnValue(JSON.stringify({ adaptive_context: { enabled: true } }));
377
+ process.env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT = "0";
378
+ const config = loadConfig();
379
+ expect(config.adaptive_context.enabled).toBe(false);
380
+ });
307
381
  });
package/lib/storage.mjs CHANGED
@@ -30,6 +30,7 @@ import { getConfigDir } from "./config.mjs";
30
30
  * @property {number | null} lastFailureTime
31
31
  * @property {string} [lastSwitchReason]
32
32
  * @property {AccountStats} stats
33
+ * @property {import('./accounts.mjs').AccountSource} [source] - Origin of the account ('oauth' | 'cc-keychain' | 'cc-file').
33
34
  */
34
35
 
35
36
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [