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 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(input, requestInit, accessToken, body, requestUrl, {
1992
- enabled: getSignatureEmulationEnabled(),
1993
- claudeCliVersion,
1994
- customBetas: config.custom_betas,
1995
- strategy: getEffectiveStrategy(),
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 N messages (recent work).
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
- // Expand tail if it would end with an assistant message
2156
- while (
2157
- tailCount < msgs.length - 2 &&
2158
- msgs[msgs.length - tailCount]?.role !== "user" &&
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.push({
2167
- role: "user",
2168
- content: [{ type: "text", text: "Continue." }],
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 — always-on for eligible models (Claude Code includes this for OAuth too)
3700
- if (hasOneMillionContext(model)) {
3701
- betas.push("context-1m-2025-08-07");
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
- parsed.messages.push({
4123
- role: "user",
4124
- content: [{ type: "text", text: "Continue." }],
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
- if (match && match.expiresAt > Date.now()) {
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
 
@@ -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 = execSync(`security find-generic-password -s "${service}" -w`, {
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
- if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
155
- throw new Error("unexpected cmd");
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
- if (cmd.includes("find-generic-password")) {
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 returns one credential
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
- if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
324
- throw new Error("unexpected");
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
- if (cmd.includes("find-generic-password")) return JSON.stringify(keychainCred);
347
- throw new Error("unexpected");
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
 
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [