opencode-anthropic-fix 0.0.31 → 0.0.33
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 +296 -32
- package/lib/accounts.mjs +1 -0
- package/lib/cc-credentials.mjs +2 -2
- package/lib/cc-credentials.test.mjs +29 -15
- package/lib/config.mjs +48 -0
- package/lib/config.test.mjs +74 -0
- package/lib/storage.mjs +4 -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,23 @@ 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: { ...config.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
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
837
|
+
}
|
|
838
|
+
},
|
|
821
839
|
};
|
|
822
840
|
|
|
823
841
|
if (!key || !setters[key]) {
|
|
@@ -1808,6 +1826,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1808
1826
|
let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
|
|
1809
1827
|
let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
|
|
1810
1828
|
let consecutive529Count = 0;
|
|
1829
|
+
let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
|
|
1830
|
+
let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
|
|
1811
1831
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1812
1832
|
// Select account — use pinned account on first attempt if available
|
|
1813
1833
|
const account =
|
|
@@ -1956,6 +1976,36 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1956
1976
|
requestInit.body,
|
|
1957
1977
|
);
|
|
1958
1978
|
const _reqProvider = detectProvider(requestUrl);
|
|
1979
|
+
|
|
1980
|
+
// --- Adaptive 1M context decision (once per logical request, not per retry) ---
|
|
1981
|
+
if (!_adaptiveDecisionMade) {
|
|
1982
|
+
_adaptiveDecisionMade = true;
|
|
1983
|
+
const _prevAdaptiveState = adaptiveContextState.active;
|
|
1984
|
+
const _use1MContext = resolveAdaptiveContext(
|
|
1985
|
+
requestInit.body,
|
|
1986
|
+
_reqModel,
|
|
1987
|
+
config.adaptive_context || {
|
|
1988
|
+
enabled: true,
|
|
1989
|
+
escalation_threshold: 150_000,
|
|
1990
|
+
deescalation_threshold: 100_000,
|
|
1991
|
+
},
|
|
1992
|
+
);
|
|
1993
|
+
// Emit visual cue on state transitions (only when adaptive mode is on)
|
|
1994
|
+
if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
|
|
1995
|
+
const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
|
|
1996
|
+
const variant = adaptiveContextState.active ? "info" : "success";
|
|
1997
|
+
const est = estimatePromptTokens(requestInit.body);
|
|
1998
|
+
toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
|
|
1999
|
+
debounceKey: "adaptive-ctx",
|
|
2000
|
+
}).catch(() => {});
|
|
2001
|
+
}
|
|
2002
|
+
_adaptiveOverrideForRequest = config.adaptive_context?.enabled
|
|
2003
|
+
? { use1MContext: _use1MContext }
|
|
2004
|
+
: undefined;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
const _adaptiveOverride = _adaptiveOverrideForRequest;
|
|
2008
|
+
|
|
1959
2009
|
const computedBetaHeader = buildAnthropicBetaHeader(
|
|
1960
2010
|
"",
|
|
1961
2011
|
getSignatureEmulationEnabled(),
|
|
@@ -1965,6 +2015,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1965
2015
|
getEffectiveStrategy(),
|
|
1966
2016
|
requestUrl?.pathname,
|
|
1967
2017
|
_reqHasFileRefs,
|
|
2018
|
+
_adaptiveOverride,
|
|
1968
2019
|
);
|
|
1969
2020
|
|
|
1970
2021
|
let body = transformRequestBody(
|
|
@@ -1988,12 +2039,20 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1988
2039
|
logTransformedSystemPrompt(body);
|
|
1989
2040
|
|
|
1990
2041
|
// Build headers with the selected account's token
|
|
1991
|
-
const requestHeaders = buildRequestHeaders(
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2042
|
+
const requestHeaders = buildRequestHeaders(
|
|
2043
|
+
input,
|
|
2044
|
+
requestInit,
|
|
2045
|
+
accessToken,
|
|
2046
|
+
body,
|
|
2047
|
+
requestUrl,
|
|
2048
|
+
{
|
|
2049
|
+
enabled: getSignatureEmulationEnabled(),
|
|
2050
|
+
claudeCliVersion,
|
|
2051
|
+
customBetas: config.custom_betas,
|
|
2052
|
+
strategy: getEffectiveStrategy(),
|
|
2053
|
+
},
|
|
2054
|
+
_adaptiveOverride,
|
|
2055
|
+
);
|
|
1997
2056
|
// Execute the request
|
|
1998
2057
|
let response;
|
|
1999
2058
|
try {
|
|
@@ -2144,29 +2203,45 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2144
2203
|
!requestInit._reactiveCompactAttempted
|
|
2145
2204
|
) {
|
|
2146
2205
|
debugLog("prompt too long — attempting reactive message trimming");
|
|
2206
|
+
// Auto-escalate adaptive context on prompt_too_long so the retry
|
|
2207
|
+
// includes the 1M beta header (if model supports it).
|
|
2208
|
+
if (config.adaptive_context?.enabled) {
|
|
2209
|
+
forceEscalateAdaptiveContext();
|
|
2210
|
+
toast("⬡ 1M context force-activated (prompt too long)", "warning", {
|
|
2211
|
+
debounceKey: "adaptive-ctx",
|
|
2212
|
+
}).catch(() => {});
|
|
2213
|
+
}
|
|
2147
2214
|
try {
|
|
2148
2215
|
const parsedBody = JSON.parse(body);
|
|
2149
2216
|
if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
|
|
2150
|
-
// Keep first 2 messages (initial context) and last
|
|
2217
|
+
// Keep first 2 messages (initial context) and last 2 messages (recent work).
|
|
2151
2218
|
// Ensure the trimmed array never ends with an assistant message (prefill),
|
|
2152
2219
|
// which would cause "does not support assistant message prefill" errors.
|
|
2153
|
-
let tailCount = 2;
|
|
2154
2220
|
const msgs = parsedBody.messages;
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
msgs.length - tailCount > 0
|
|
2160
|
-
) {
|
|
2161
|
-
tailCount++;
|
|
2162
|
-
}
|
|
2163
|
-
const tail = msgs.slice(-tailCount);
|
|
2164
|
-
// If tail still starts/ends badly, force a user wrapper
|
|
2221
|
+
const tail = msgs.slice(-2);
|
|
2222
|
+
// If tail ends with assistant, append a user message to fix the prefill issue.
|
|
2223
|
+
// Check if the assistant's last content block is tool_use — if so, synthesize
|
|
2224
|
+
// a tool_result instead of bare "Continue." to respect the tool protocol.
|
|
2165
2225
|
if (tail.length > 0 && tail[tail.length - 1]?.role === "assistant") {
|
|
2166
|
-
tail.
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2226
|
+
const lastAssistant = tail[tail.length - 1];
|
|
2227
|
+
const lastContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
|
|
2228
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
2229
|
+
if (toolUseBlocks.length > 0) {
|
|
2230
|
+
// Synthesize tool_result for each pending tool_use
|
|
2231
|
+
tail.push({
|
|
2232
|
+
role: "user",
|
|
2233
|
+
content: toolUseBlocks.map((tu) => ({
|
|
2234
|
+
type: "tool_result",
|
|
2235
|
+
tool_use_id: tu.id,
|
|
2236
|
+
content: "[Context trimmed — previous result unavailable]",
|
|
2237
|
+
})),
|
|
2238
|
+
});
|
|
2239
|
+
} else {
|
|
2240
|
+
tail.push({
|
|
2241
|
+
role: "user",
|
|
2242
|
+
content: [{ type: "text", text: "Continue." }],
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2170
2245
|
}
|
|
2171
2246
|
const trimmed = [
|
|
2172
2247
|
...msgs.slice(0, 2),
|
|
@@ -2575,6 +2650,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2575
2650
|
],
|
|
2576
2651
|
},
|
|
2577
2652
|
"experimental.session.compacting": async (input, output) => {
|
|
2653
|
+
// Reset adaptive context state on session compaction (new conversation boundary).
|
|
2654
|
+
// This prevents sticky escalation from leaking across conversations.
|
|
2655
|
+
adaptiveContextState.active = false;
|
|
2656
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
2657
|
+
adaptiveContextState.escalatedByError = false;
|
|
2658
|
+
|
|
2578
2659
|
// Inject Anthropic-specific context into compaction
|
|
2579
2660
|
if (!accountManager) return;
|
|
2580
2661
|
const account = accountManager.getCurrentAccount();
|
|
@@ -2618,6 +2699,161 @@ const sessionMetrics = {
|
|
|
2618
2699
|
lastQuota: { tokens: 0, requests: 0, inputTokens: 0, updatedAt: 0 },
|
|
2619
2700
|
};
|
|
2620
2701
|
|
|
2702
|
+
// ---------------------------------------------------------------------------
|
|
2703
|
+
// Adaptive 1M context state
|
|
2704
|
+
// ---------------------------------------------------------------------------
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Tracks whether the 1M context beta is currently active for adaptive mode.
|
|
2708
|
+
* When adaptive_context.enabled is true, the context-1m-2025-08-07 beta is
|
|
2709
|
+
* toggled per-request based on estimated prompt size.
|
|
2710
|
+
*
|
|
2711
|
+
* @type {{ active: boolean, lastTransitionTurn: number, escalatedByError: boolean }}
|
|
2712
|
+
*/
|
|
2713
|
+
const adaptiveContextState = {
|
|
2714
|
+
/** Whether 1M context beta is currently being sent. */
|
|
2715
|
+
active: false,
|
|
2716
|
+
/** Turn number of the last transition (to avoid flapping). */
|
|
2717
|
+
lastTransitionTurn: 0,
|
|
2718
|
+
/** Set when escalation was triggered by a prompt_too_long error. */
|
|
2719
|
+
escalatedByError: false,
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* Estimate prompt token count from the raw request body string.
|
|
2724
|
+
* Uses a 4-character-per-token heuristic (conservative for English + code).
|
|
2725
|
+
* @param {string} bodyString - JSON string of the request body
|
|
2726
|
+
* @returns {number} Estimated token count
|
|
2727
|
+
*/
|
|
2728
|
+
function estimatePromptTokens(bodyString) {
|
|
2729
|
+
if (!bodyString || typeof bodyString !== "string") return 0;
|
|
2730
|
+
try {
|
|
2731
|
+
const parsed = JSON.parse(bodyString);
|
|
2732
|
+
let charCount = 0;
|
|
2733
|
+
|
|
2734
|
+
// Count system prompt text
|
|
2735
|
+
if (Array.isArray(parsed.system)) {
|
|
2736
|
+
for (const block of parsed.system) {
|
|
2737
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2738
|
+
charCount += block.text.length;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
} else if (typeof parsed.system === "string") {
|
|
2742
|
+
charCount += parsed.system.length;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// Count messages content (text blocks, tool results) — skip tool definitions
|
|
2746
|
+
if (Array.isArray(parsed.messages)) {
|
|
2747
|
+
for (const msg of parsed.messages) {
|
|
2748
|
+
if (typeof msg.content === "string") {
|
|
2749
|
+
charCount += msg.content.length;
|
|
2750
|
+
} else if (Array.isArray(msg.content)) {
|
|
2751
|
+
for (const block of msg.content) {
|
|
2752
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2753
|
+
charCount += block.text.length;
|
|
2754
|
+
} else if (block.type === "tool_result" && typeof block.content === "string") {
|
|
2755
|
+
charCount += block.content.length;
|
|
2756
|
+
} else if (block.type === "tool_use") {
|
|
2757
|
+
// Count serialized input as tokens
|
|
2758
|
+
charCount += JSON.stringify(block.input || {}).length;
|
|
2759
|
+
} else if (block.type === "image" || block.type === "image_url") {
|
|
2760
|
+
// Images: ~2000 tokens per image (Anthropic tile-based counting)
|
|
2761
|
+
charCount += 8000; // 2000 tokens * 4 chars/token
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
2769
|
+
return Math.ceil(charCount / 4);
|
|
2770
|
+
} catch {
|
|
2771
|
+
// Fallback: raw body length / 4 if JSON parsing fails
|
|
2772
|
+
return Math.ceil(bodyString.length / 4);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* Decide whether to include the context-1m beta for this request.
|
|
2778
|
+
* Returns true if 1M context should be activated.
|
|
2779
|
+
*
|
|
2780
|
+
* Decision logic:
|
|
2781
|
+
* - If adaptive_context is disabled, defer to hasOneMillionContext(model) as before.
|
|
2782
|
+
* - If model does not support 1M context, always false.
|
|
2783
|
+
* - Escalate when estimated prompt tokens exceed escalation_threshold.
|
|
2784
|
+
* - De-escalate when estimated prompt tokens drop below deescalation_threshold.
|
|
2785
|
+
* - Never de-escalate if escalation was triggered by a prompt_too_long error
|
|
2786
|
+
* (sticky until session compacts or drops far below threshold).
|
|
2787
|
+
* - Hysteresis: require at least 2 turns between transitions to avoid flapping.
|
|
2788
|
+
*
|
|
2789
|
+
* @param {string} bodyString - JSON request body
|
|
2790
|
+
* @param {string} model - Model ID
|
|
2791
|
+
* @param {import('./lib/config.mjs').AdaptiveContextConfig} adaptiveConfig
|
|
2792
|
+
* @returns {boolean}
|
|
2793
|
+
*/
|
|
2794
|
+
function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
2795
|
+
// Non-adaptive: use static check
|
|
2796
|
+
if (!adaptiveConfig.enabled) {
|
|
2797
|
+
return hasOneMillionContext(model);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
// If experimental betas are disabled, context-1m will be stripped anyway — skip adaptive logic
|
|
2801
|
+
if (isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
|
|
2802
|
+
return false;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// Model must be eligible for 1M context at all
|
|
2806
|
+
if (!hasOneMillionContext(model)) {
|
|
2807
|
+
return false;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
const estimatedTokens = estimatePromptTokens(bodyString);
|
|
2811
|
+
const turnsSinceTransition = sessionMetrics.turns - adaptiveContextState.lastTransitionTurn;
|
|
2812
|
+
|
|
2813
|
+
if (adaptiveContextState.active) {
|
|
2814
|
+
// Currently active — consider de-escalation
|
|
2815
|
+
// Don't de-escalate if error-escalated (sticky)
|
|
2816
|
+
if (adaptiveContextState.escalatedByError) {
|
|
2817
|
+
return true;
|
|
2818
|
+
}
|
|
2819
|
+
// Hysteresis: require at least 2 turns before considering de-escalation
|
|
2820
|
+
if (turnsSinceTransition < 2) {
|
|
2821
|
+
return true;
|
|
2822
|
+
}
|
|
2823
|
+
if (estimatedTokens < adaptiveConfig.deescalation_threshold) {
|
|
2824
|
+
// De-escalate
|
|
2825
|
+
adaptiveContextState.active = false;
|
|
2826
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
2827
|
+
return false;
|
|
2828
|
+
}
|
|
2829
|
+
return true;
|
|
2830
|
+
} else {
|
|
2831
|
+
// Currently inactive — consider escalation
|
|
2832
|
+
// Symmetric hysteresis: require at least 2 turns before re-escalation too
|
|
2833
|
+
if (turnsSinceTransition < 2 && adaptiveContextState.lastTransitionTurn > 0) {
|
|
2834
|
+
return false;
|
|
2835
|
+
}
|
|
2836
|
+
if (estimatedTokens > adaptiveConfig.escalation_threshold) {
|
|
2837
|
+
// Escalate
|
|
2838
|
+
adaptiveContextState.active = true;
|
|
2839
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
2840
|
+
return true;
|
|
2841
|
+
}
|
|
2842
|
+
return false;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* Force-escalate adaptive context (e.g. after prompt_too_long error).
|
|
2848
|
+
*/
|
|
2849
|
+
function forceEscalateAdaptiveContext() {
|
|
2850
|
+
if (!adaptiveContextState.active) {
|
|
2851
|
+
adaptiveContextState.active = true;
|
|
2852
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
2853
|
+
}
|
|
2854
|
+
adaptiveContextState.escalatedByError = true;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2621
2857
|
const MODEL_PRICING = {
|
|
2622
2858
|
"claude-opus-4-6": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
2623
2859
|
"claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
@@ -3644,6 +3880,7 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
3644
3880
|
* @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
|
|
3645
3881
|
* @param {string} [requestPath]
|
|
3646
3882
|
* @param {boolean} [hasFileReferences]
|
|
3883
|
+
* @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
|
|
3647
3884
|
* @returns {string}
|
|
3648
3885
|
*/
|
|
3649
3886
|
function buildAnthropicBetaHeader(
|
|
@@ -3655,6 +3892,7 @@ function buildAnthropicBetaHeader(
|
|
|
3655
3892
|
strategy,
|
|
3656
3893
|
requestPath,
|
|
3657
3894
|
hasFileReferences,
|
|
3895
|
+
adaptiveOverride,
|
|
3658
3896
|
) {
|
|
3659
3897
|
const incomingBetasList = incomingBeta
|
|
3660
3898
|
.split(",")
|
|
@@ -3696,9 +3934,15 @@ function buildAnthropicBetaHeader(
|
|
|
3696
3934
|
betas.push("interleaved-thinking-2025-05-14");
|
|
3697
3935
|
}
|
|
3698
3936
|
|
|
3699
|
-
// Context 1M —
|
|
3700
|
-
|
|
3701
|
-
|
|
3937
|
+
// Context 1M — when adaptive override is provided, use it; otherwise fall back to static check.
|
|
3938
|
+
{
|
|
3939
|
+
const use1M =
|
|
3940
|
+
adaptiveOverride && typeof adaptiveOverride.use1MContext === "boolean"
|
|
3941
|
+
? adaptiveOverride.use1MContext
|
|
3942
|
+
: hasOneMillionContext(model);
|
|
3943
|
+
if (use1M) {
|
|
3944
|
+
betas.push("context-1m-2025-08-07");
|
|
3945
|
+
}
|
|
3702
3946
|
}
|
|
3703
3947
|
|
|
3704
3948
|
// Prompt caching scope — always-on EXCEPT in round-robin (per-workspace state conflicts)
|
|
@@ -3887,7 +4131,7 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
|
|
|
3887
4131
|
* @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[]}} signature
|
|
3888
4132
|
* @returns {Headers}
|
|
3889
4133
|
*/
|
|
3890
|
-
function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
|
|
4134
|
+
function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature, adaptiveOverride) {
|
|
3891
4135
|
const requestHeaders = new Headers();
|
|
3892
4136
|
if (input instanceof Request) {
|
|
3893
4137
|
input.headers.forEach((value, key) => {
|
|
@@ -3927,6 +4171,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
|
|
|
3927
4171
|
signature.strategy,
|
|
3928
4172
|
requestUrl?.pathname,
|
|
3929
4173
|
hasFileReferences,
|
|
4174
|
+
adaptiveOverride,
|
|
3930
4175
|
);
|
|
3931
4176
|
|
|
3932
4177
|
const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
@@ -4119,10 +4364,26 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4119
4364
|
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
4120
4365
|
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
4121
4366
|
if (lastMsg && lastMsg.role === "assistant") {
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4367
|
+
// Check if the assistant message contains tool_use blocks — if so,
|
|
4368
|
+
// synthesize tool_result responses instead of bare "Continue." to
|
|
4369
|
+
// respect the Anthropic tool protocol.
|
|
4370
|
+
const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
|
4371
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
4372
|
+
if (toolUseBlocks.length > 0) {
|
|
4373
|
+
parsed.messages.push({
|
|
4374
|
+
role: "user",
|
|
4375
|
+
content: toolUseBlocks.map((tu) => ({
|
|
4376
|
+
type: "tool_result",
|
|
4377
|
+
tool_use_id: tu.id,
|
|
4378
|
+
content: "[Result unavailable — conversation was restructured]",
|
|
4379
|
+
})),
|
|
4380
|
+
});
|
|
4381
|
+
} else {
|
|
4382
|
+
parsed.messages.push({
|
|
4383
|
+
role: "user",
|
|
4384
|
+
content: [{ type: "text", text: "Continue." }],
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
4126
4387
|
}
|
|
4127
4388
|
}
|
|
4128
4389
|
|
|
@@ -4577,9 +4838,12 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
|
|
|
4577
4838
|
const { readCCCredentials } = await import("./lib/cc-credentials.mjs");
|
|
4578
4839
|
const ccCreds = readCCCredentials();
|
|
4579
4840
|
const match = ccCreds.find((c) => c.refreshToken === account.refreshToken);
|
|
4580
|
-
|
|
4841
|
+
// Accept CC credential if:
|
|
4842
|
+
// - expiresAt is in the future (normal case), OR
|
|
4843
|
+
// - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
|
|
4844
|
+
if (match && (match.expiresAt === 0 || match.expiresAt > Date.now())) {
|
|
4581
4845
|
account.access = match.accessToken;
|
|
4582
|
-
account.expires = match.expiresAt;
|
|
4846
|
+
account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
|
|
4583
4847
|
markTokenStateUpdated(account);
|
|
4584
4848
|
if (onTokensUpdated) {
|
|
4585
4849
|
try {
|
package/lib/accounts.mjs
CHANGED
|
@@ -807,6 +807,7 @@ export class AccountManager {
|
|
|
807
807
|
lastFailureTime: acc.lastFailureTime,
|
|
808
808
|
lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
|
|
809
809
|
stats: acc.stats ?? existing?.stats ?? createDefaultStats(),
|
|
810
|
+
source: acc.source || existing?.source || "oauth",
|
|
810
811
|
};
|
|
811
812
|
});
|
|
812
813
|
|
package/lib/cc-credentials.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @module cc-credentials
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { execSync } from "node:child_process";
|
|
19
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
20
20
|
import { readFileSync, existsSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { homedir } from "node:os";
|
|
@@ -77,7 +77,7 @@ function listKeychainServices() {
|
|
|
77
77
|
*/
|
|
78
78
|
function readKeychainService(service) {
|
|
79
79
|
try {
|
|
80
|
-
const raw =
|
|
80
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", service, "-w"], {
|
|
81
81
|
timeout: 5_000,
|
|
82
82
|
encoding: "utf-8",
|
|
83
83
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
vi.mock("node:child_process", () => ({
|
|
14
14
|
execSync: vi.fn(),
|
|
15
|
+
execFileSync: vi.fn(),
|
|
15
16
|
}));
|
|
16
17
|
|
|
17
18
|
vi.mock("node:fs", () => ({
|
|
@@ -23,7 +24,7 @@ vi.mock("node:os", () => ({
|
|
|
23
24
|
homedir: vi.fn(() => "/home/testuser"),
|
|
24
25
|
}));
|
|
25
26
|
|
|
26
|
-
import { execSync } from "node:child_process";
|
|
27
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
27
28
|
import { readFileSync, existsSync } from "node:fs";
|
|
28
29
|
import { homedir } from "node:os";
|
|
29
30
|
|
|
@@ -149,10 +150,14 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
149
150
|
it("reads single keychain service on macOS", () => {
|
|
150
151
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
151
152
|
|
|
153
|
+
// dump-keychain uses execSync, find-generic-password uses execFileSync
|
|
152
154
|
execSync.mockImplementation((cmd) => {
|
|
153
155
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
throw new Error("unexpected execSync cmd");
|
|
157
|
+
});
|
|
158
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
159
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
160
|
+
throw new Error("unexpected execFileSync cmd");
|
|
156
161
|
});
|
|
157
162
|
|
|
158
163
|
const result = readCCCredentialsFromKeychain();
|
|
@@ -168,12 +173,15 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
168
173
|
let callIdx = 0;
|
|
169
174
|
execSync.mockImplementation((cmd) => {
|
|
170
175
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_MULTI;
|
|
171
|
-
|
|
176
|
+
throw new Error("unexpected execSync cmd");
|
|
177
|
+
});
|
|
178
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
179
|
+
if (args && args.includes("find-generic-password")) {
|
|
172
180
|
callIdx++;
|
|
173
181
|
const cred = callIdx === 1 ? VALID_CRED : VALID_CRED_2;
|
|
174
182
|
return JSON.stringify(cred);
|
|
175
183
|
}
|
|
176
|
-
throw new Error("unexpected cmd");
|
|
184
|
+
throw new Error("unexpected execFileSync cmd");
|
|
177
185
|
});
|
|
178
186
|
|
|
179
187
|
const result = readCCCredentialsFromKeychain();
|
|
@@ -197,13 +205,13 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
197
205
|
|
|
198
206
|
execSync.mockImplementation((cmd) => {
|
|
199
207
|
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
208
|
throw new Error("unexpected");
|
|
206
209
|
});
|
|
210
|
+
execFileSync.mockImplementation(() => {
|
|
211
|
+
const err = new Error("not found");
|
|
212
|
+
err.status = 44;
|
|
213
|
+
throw err;
|
|
214
|
+
});
|
|
207
215
|
|
|
208
216
|
expect(readCCCredentialsFromKeychain()).toEqual([]);
|
|
209
217
|
});
|
|
@@ -317,11 +325,14 @@ describe("readCCCredentials", () => {
|
|
|
317
325
|
it("combines keychain and file sources on macOS", () => {
|
|
318
326
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
319
327
|
|
|
320
|
-
// Keychain
|
|
328
|
+
// Keychain: dump-keychain via execSync, find-generic-password via execFileSync
|
|
321
329
|
execSync.mockImplementation((cmd) => {
|
|
322
330
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
throw new Error("unexpected execSync cmd");
|
|
332
|
+
});
|
|
333
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
334
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
335
|
+
throw new Error("unexpected execFileSync cmd");
|
|
325
336
|
});
|
|
326
337
|
|
|
327
338
|
// File returns a different credential
|
|
@@ -343,8 +354,11 @@ describe("readCCCredentials", () => {
|
|
|
343
354
|
|
|
344
355
|
execSync.mockImplementation((cmd) => {
|
|
345
356
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
346
|
-
|
|
347
|
-
|
|
357
|
+
throw new Error("unexpected execSync cmd");
|
|
358
|
+
});
|
|
359
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
360
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(keychainCred);
|
|
361
|
+
throw new Error("unexpected execFileSync cmd");
|
|
348
362
|
});
|
|
349
363
|
|
|
350
364
|
existsSync.mockReturnValue(true);
|
package/lib/config.mjs
CHANGED
|
@@ -53,6 +53,13 @@ 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
|
+
|
|
56
63
|
/**
|
|
57
64
|
* @typedef {object} CCCredentialReuseConfig
|
|
58
65
|
* @property {boolean} enabled - Master switch for CC credential reuse. Default: false.
|
|
@@ -78,6 +85,7 @@ import { randomBytes } from "node:crypto";
|
|
|
78
85
|
* @property {{emulate_minimal: boolean}} telemetry
|
|
79
86
|
* @property {boolean} usage_toast
|
|
80
87
|
* @property {CCCredentialReuseConfig} cc_credential_reuse
|
|
88
|
+
* @property {AdaptiveContextConfig} adaptive_context
|
|
81
89
|
*/
|
|
82
90
|
|
|
83
91
|
/** @type {AnthropicAuthConfig} */
|
|
@@ -141,6 +149,11 @@ export const DEFAULT_CONFIG = {
|
|
|
141
149
|
auto_detect: true,
|
|
142
150
|
prefer_over_oauth: false,
|
|
143
151
|
},
|
|
152
|
+
adaptive_context: {
|
|
153
|
+
enabled: true,
|
|
154
|
+
escalation_threshold: 150_000,
|
|
155
|
+
deescalation_threshold: 100_000,
|
|
156
|
+
},
|
|
144
157
|
};
|
|
145
158
|
|
|
146
159
|
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
|
|
@@ -167,6 +180,7 @@ function createDefaultConfig() {
|
|
|
167
180
|
telemetry: { ...DEFAULT_CONFIG.telemetry },
|
|
168
181
|
headers: { ...DEFAULT_CONFIG.headers, overrides: {}, disable: [] },
|
|
169
182
|
cc_credential_reuse: { ...DEFAULT_CONFIG.cc_credential_reuse },
|
|
183
|
+
adaptive_context: { ...DEFAULT_CONFIG.adaptive_context },
|
|
170
184
|
};
|
|
171
185
|
}
|
|
172
186
|
|
|
@@ -385,6 +399,33 @@ function validateConfig(raw) {
|
|
|
385
399
|
};
|
|
386
400
|
}
|
|
387
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
|
+
// Cross-validate: deescalation must be strictly less than escalation to maintain hysteresis gap
|
|
421
|
+
if (config.adaptive_context.deescalation_threshold >= config.adaptive_context.escalation_threshold) {
|
|
422
|
+
config.adaptive_context.deescalation_threshold = Math.max(
|
|
423
|
+
20_000,
|
|
424
|
+
config.adaptive_context.escalation_threshold - 50_000,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
388
429
|
return config;
|
|
389
430
|
}
|
|
390
431
|
|
|
@@ -464,6 +505,13 @@ function applyEnvOverrides(config) {
|
|
|
464
505
|
config.cc_credential_reuse.enabled = false;
|
|
465
506
|
}
|
|
466
507
|
|
|
508
|
+
if (env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "1" || env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "true") {
|
|
509
|
+
config.adaptive_context.enabled = true;
|
|
510
|
+
}
|
|
511
|
+
if (env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "0" || env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT === "false") {
|
|
512
|
+
config.adaptive_context.enabled = false;
|
|
513
|
+
}
|
|
514
|
+
|
|
467
515
|
return config;
|
|
468
516
|
}
|
|
469
517
|
|
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 (enabled)", () => {
|
|
312
|
+
existsSync.mockReturnValue(false);
|
|
313
|
+
const config = loadConfig();
|
|
314
|
+
expect(config.adaptive_context).toEqual({
|
|
315
|
+
enabled: true,
|
|
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(true);
|
|
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
|
@@ -183,6 +183,10 @@ function validateAccount(raw, now) {
|
|
|
183
183
|
typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
|
|
184
184
|
lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
|
|
185
185
|
lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
|
|
186
|
+
source:
|
|
187
|
+
typeof acc.source === "string" && ["oauth", "cc-keychain", "cc-file"].includes(acc.source)
|
|
188
|
+
? acc.source
|
|
189
|
+
: undefined,
|
|
186
190
|
stats: validateStats(acc.stats, now),
|
|
187
191
|
};
|
|
188
192
|
}
|