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 +211 -10
- package/lib/accounts.mjs +92 -0
- package/lib/cc-credentials.mjs +253 -0
- package/lib/cc-credentials.test.mjs +377 -0
- package/lib/config.mjs +76 -0
- package/lib/config.test.mjs +74 -0
- package/lib/storage.mjs +1 -0
- package/package.json +1 -1
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(
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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 —
|
|
3698
|
-
|
|
3699
|
-
|
|
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
|
|
package/lib/config.test.mjs
CHANGED
|
@@ -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
|
/**
|