opencode-anthropic-fix 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.mjs +134 -10
  2. package/lib/config.mjs +33 -0
  3. package/package.json +1 -1
package/index.mjs CHANGED
@@ -3041,7 +3041,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3041
3041
  sessionMetrics.lastQuota.inputTokens = maxUtilization;
3042
3042
  }
3043
3043
 
3044
- if (maxUtilization > 0.8) {
3044
+ // Proactive account management is gated on config. When
3045
+ // account_management.proactive_disabled is true (default),
3046
+ // we never apply penalties on a 200 OK response — those
3047
+ // penalties were locking out single-account users whose
3048
+ // server-side quota was still in `allowed_warning` state.
3049
+ // The reactive 429 path below is unaffected.
3050
+ const proactiveDisabled = config.account_management?.proactive_disabled !== false;
3051
+
3052
+ if (!proactiveDisabled && maxUtilization > 0.8) {
3045
3053
  const penalty = Math.round((maxUtilization - 0.8) * 50); // 0-10 points
3046
3054
  accountManager.applyUtilizationPenalty(account, penalty);
3047
3055
  debugLog("high rate limit utilization", {
@@ -3052,7 +3060,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3052
3060
  });
3053
3061
  }
3054
3062
 
3055
- if (anySurpassed) {
3063
+ if (!proactiveDisabled && anySurpassed) {
3056
3064
  accountManager.applySurpassedThreshold(account, surpassedResetAt);
3057
3065
  debugLog("rate limit threshold surpassed", {
3058
3066
  accountIndex: account.index,
@@ -3070,8 +3078,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3070
3078
  }
3071
3079
 
3072
3080
  // Predictive rate limit avoidance: switch account BEFORE hitting 429
3073
- // Parse reset timestamps to compute time-weighted risk
3074
- if (maxUtilization > 0.6 && accountManager.getAccountCount() > 1) {
3081
+ // Parse reset timestamps to compute time-weighted risk.
3082
+ // Gated on proactive_disabled when true (default), no automatic
3083
+ // switches happen on 200 OK responses (fully manual rotation).
3084
+ if (!proactiveDisabled && maxUtilization > 0.6 && accountManager.getAccountCount() > 1) {
3075
3085
  let highestRisk = 0;
3076
3086
  for (const win of RATE_LIMIT_WINDOWS) {
3077
3087
  const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-utilization`);
@@ -5722,6 +5732,24 @@ function buildAnthropicBillingHeader(version, firstUserMessage, provider) {
5722
5732
  // Opencode's customizations after ~5800 chars diverge and trigger extra usage billing.
5723
5733
  const MAX_SAFE_SYSTEM_TEXT_LENGTH = 5000;
5724
5734
 
5735
+ // A5: Subagent CC-prefix cache.
5736
+ //
5737
+ // Context: opencode/packages/opencode/src/session/llm.ts:110 uses
5738
+ // `input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(model)`
5739
+ // so any agent with a custom prompt (explore, fast, title, summary, etc.)
5740
+ // fires WITHOUT the base CC prompt — the server-side fingerprint match fails
5741
+ // and the request is billed as pay-as-you-go credits instead of Max-plan usage.
5742
+ //
5743
+ // Fix: on the first main-agent call (where the anchor is present), cache the
5744
+ // sanitized CC prefix. On subsequent subagent calls (anchor missing), prepend
5745
+ // the cached prefix to the sanitized blocks so the fingerprint matches again.
5746
+ //
5747
+ // The cache lives at module scope because buildSystemPromptBlocks is re-entered
5748
+ // per request. It gets populated exactly once per process on the first main call.
5749
+ const MAX_SUBAGENT_CC_PREFIX = MAX_SAFE_SYSTEM_TEXT_LENGTH;
5750
+ const SUBAGENT_CC_ANCHOR = "You are an interactive";
5751
+ let cachedCCPrompt = null;
5752
+
5725
5753
  function sanitizeSystemText(text) {
5726
5754
  // QA fix M4: use word boundaries to avoid mangling URLs and code identifiers
5727
5755
  let sanitized = text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude");
@@ -6035,6 +6063,40 @@ function buildSystemPromptBlocks(system, signature) {
6035
6063
  text: compactSystemText(sanitizeSystemText(item.text), signature.promptCompactionMode),
6036
6064
  }));
6037
6065
 
6066
+ // A5: Subagent CC-prefix cache/inject (see constant declaration above for context).
6067
+ //
6068
+ // After sanitize, main-agent blocks start with "You are an interactive..." because
6069
+ // sanitizeSystemText() strips everything before that anchor. Subagent blocks
6070
+ // (custom prompts from input.agent.prompt) do NOT start with the anchor —
6071
+ // they start with whatever the agent template says (e.g., "You are a file search
6072
+ // specialist.").
6073
+ //
6074
+ // This logic runs ONLY for Anthropic requests with signature enabled (signature.enabled
6075
+ // is false for non-Anthropic providers), and skips the title-generator fast path
6076
+ // because that one is replaced wholesale with COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT below.
6077
+ if (signature.enabled && !titleGeneratorRequest && sanitized.length > 0) {
6078
+ const firstText = typeof sanitized[0]?.text === "string" ? sanitized[0].text : "";
6079
+ const hasCcAnchor = firstText.startsWith(SUBAGENT_CC_ANCHOR);
6080
+
6081
+ if (hasCcAnchor) {
6082
+ // Main-agent path: cache the prefix on the first hit so subagents can reuse it.
6083
+ // We slice to MAX_SUBAGENT_CC_PREFIX to avoid unbounded growth if the upstream
6084
+ // sanitize limit is ever raised.
6085
+ if (!cachedCCPrompt) {
6086
+ cachedCCPrompt = firstText.slice(0, MAX_SUBAGENT_CC_PREFIX);
6087
+ }
6088
+ } else if (cachedCCPrompt) {
6089
+ // Subagent path: prepend the cached CC prefix so the fingerprint matches.
6090
+ // We prepend, not concatenate, so the original subagent prompt stays as a
6091
+ // separate block — dedupeSystemBlocks and splitSysPromptPrefix handle the
6092
+ // join on their own downstream.
6093
+ sanitized = [{ type: "text", text: cachedCCPrompt }, ...sanitized];
6094
+ }
6095
+ // If !hasCcAnchor && !cachedCCPrompt: no-op. The cache primes on the very
6096
+ // first main call in a process. In practice opencode always fires a main
6097
+ // call before any subagent, so this branch is only hit in synthetic tests.
6098
+ }
6099
+
6038
6100
  if (titleGeneratorRequest) {
6039
6101
  sanitized = [{ type: "text", text: COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT }];
6040
6102
  } else if (signature.promptCompactionMode !== "off") {
@@ -6133,9 +6195,10 @@ function buildAnthropicBetaHeader(
6133
6195
 
6134
6196
  // === ALWAYS-ON BETAS (Claude Code v2.1.90 base set) ===
6135
6197
  // These are ALWAYS included regardless of env vars or feature flags.
6136
- if (!haiku) {
6137
- betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
6138
- }
6198
+ // NOTE: Real Claude Code skips this beta for Haiku, but we include it
6199
+ // so that Haiku subagents (via model-router delegation) get full mimic
6200
+ // behavior from the Anthropic API.
6201
+ betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
6139
6202
 
6140
6203
  // Tool search: use provider-aware header.
6141
6204
  // 1P/Foundry u2192 advanced-tool-use-2025-11-20 (enables broader tool capabilities)
@@ -6147,10 +6210,20 @@ function buildAnthropicBetaHeader(
6147
6210
  }
6148
6211
 
6149
6212
  betas.push(FAST_MODE_BETA_FLAG); // "fast-mode-2026-02-01"
6150
- betas.push(EFFORT_BETA_FLAG); // "effort-2025-11-24"
6151
6213
 
6152
- // Interleaved thinking always-on unless explicitly disabled
6153
- if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING)) {
6214
+ // effort-2025-11-24real CC's Lyz() only pushes this flag when rE(model)
6215
+ // is true (Opus 4.6 / Sonnet 4.6). Pushing it for non-adaptive models like
6216
+ // Haiku is a fingerprint mismatch vs real CC and can contaminate billing
6217
+ // attribution even when the request body has no effort field.
6218
+ if (isAdaptiveThinkingModel(model)) {
6219
+ betas.push(EFFORT_BETA_FLAG); // "effort-2025-11-24"
6220
+ }
6221
+
6222
+ // Interleaved thinking — real CC's i01 pushes via hv4(model), which is
6223
+ // (firstParty && non-Claude-3). Claude 3.x models don't support interleaved
6224
+ // thinking and real CC never sends this flag for them, so emitting it
6225
+ // diverges the fingerprint for legacy Haiku/Sonnet 3.x requests.
6226
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !/claude-3-/i.test(model)) {
6154
6227
  betas.push("interleaved-thinking-2025-05-14");
6155
6228
  }
6156
6229
 
@@ -6548,6 +6621,27 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
6548
6621
  parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
6549
6622
  }
6550
6623
 
6624
+ // Fingerprint fix: real Claude Code v2.1.87+ nests the effort control inside
6625
+ // `output_config.effort` (via Lyz() in cli.js). opencode's provider transform
6626
+ // for variant=max on Opus 4.6 / Sonnet 4.6 sets `effort` at the top level,
6627
+ // which causes Anthropic's server to fingerprint the body as non-CC and bill
6628
+ // it as pay-as-you-go — surfacing as "You're out of extra usage" even on a
6629
+ // valid Max plan. Move it into output_config when we're talking to an
6630
+ // adaptive-thinking model so the wire shape matches real CC.
6631
+ if (typeof parsed.effort === "string" && parsed.model && isAdaptiveThinkingModel(parsed.model)) {
6632
+ if (!parsed.output_config || typeof parsed.output_config !== "object") {
6633
+ parsed.output_config = {};
6634
+ }
6635
+ if (!("effort" in parsed.output_config)) {
6636
+ parsed.output_config.effort = parsed.effort;
6637
+ }
6638
+ delete parsed.effort;
6639
+ } else if (Object.prototype.hasOwnProperty.call(parsed, "effort")) {
6640
+ // Non-adaptive models never carry a top-level effort in real CC — strip it
6641
+ // to avoid polluting the fingerprint for models like Haiku.
6642
+ delete parsed.effort;
6643
+ }
6644
+
6551
6645
  // Claude Code temperature rule: when extended thinking is active (any type),
6552
6646
  // temperature must be omitted (undefined). Otherwise default to 1.
6553
6647
  const thinkingActive =
@@ -7372,4 +7466,34 @@ function extractFileIds(body) {
7372
7466
  return ids;
7373
7467
  }
7374
7468
 
7469
+ // Internals exposed for tests only. Do not consume from production code paths.
7470
+ //
7471
+ // IMPORTANT: do NOT add a new `export` declaration here. Opencode's plugin
7472
+ // loader (opencode/packages/opencode/src/plugin/index.ts:74-79) iterates
7473
+ // `Object.values(mod)` of the loaded module and throws "Plugin export is not
7474
+ // a function" if ANY export is not a plugin function. A named `export const
7475
+ // __testing__ = {...}` object would break plugin loading entirely.
7476
+ //
7477
+ // Instead, attach the test hooks as a PROPERTY of the exported function.
7478
+ // Functions are objects in JS, so this is valid. The module surface still
7479
+ // has only one exported value (the AnthropicAuthPlugin function), which is
7480
+ // what the loader expects. Tests reach internals via
7481
+ // `import { AnthropicAuthPlugin } from "./index.mjs"` then
7482
+ // `AnthropicAuthPlugin.__testing__`.
7483
+ AnthropicAuthPlugin.__testing__ = {
7484
+ sanitizeSystemText,
7485
+ compactSystemText,
7486
+ dedupeSystemBlocks,
7487
+ normalizeSystemTextBlocks,
7488
+ buildSystemPromptBlocks,
7489
+ get cachedCCPrompt() {
7490
+ return cachedCCPrompt;
7491
+ },
7492
+ resetCachedCCPrompt() {
7493
+ cachedCCPrompt = null;
7494
+ },
7495
+ SUBAGENT_CC_ANCHOR,
7496
+ CLAUDE_CODE_IDENTITY_STRING,
7497
+ };
7498
+
7375
7499
  export default AnthropicAuthPlugin;
package/lib/config.mjs CHANGED
@@ -102,6 +102,7 @@ import { randomBytes } from "node:crypto";
102
102
  * @property {{ enabled: boolean, default: number, completion_threshold: number }} token_budget
103
103
  * @property {{ enabled: boolean, threshold_percent: number }} microcompact
104
104
  * @property {{ enabled: boolean, default_cooldown_ms: number, poll_quota_on_overload: boolean }} overload_recovery
105
+ * @property {{ proactive_disabled: boolean }} account_management
105
106
  */
106
107
 
107
108
  /** @type {AnthropicAuthConfig} */
@@ -241,6 +242,16 @@ export const DEFAULT_CONFIG = {
241
242
  /** Whether to poll /api/oauth/usage on 529 exhaustion for smarter cooldowns. */
242
243
  poll_quota_on_overload: true,
243
244
  },
245
+ /** Account management: control automatic account penalties and switching.
246
+ * When proactive_disabled is true (default), the plugin will NOT apply
247
+ * utilization penalties, surpassed-threshold penalties, or predictive
248
+ * switches based on response headers (200 OK responses). Reactive 429
249
+ * handling still works. This makes account switching fully manual and
250
+ * prevents single-account users from being locally locked out by warning
251
+ * thresholds the server still allows. */
252
+ account_management: {
253
+ proactive_disabled: true,
254
+ },
244
255
  };
245
256
 
246
257
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
@@ -278,6 +289,7 @@ function createDefaultConfig() {
278
289
  token_budget: { ...DEFAULT_CONFIG.token_budget },
279
290
  microcompact: { ...DEFAULT_CONFIG.microcompact },
280
291
  overload_recovery: { ...DEFAULT_CONFIG.overload_recovery },
292
+ account_management: { ...DEFAULT_CONFIG.account_management },
281
293
  };
282
294
  }
283
295
 
@@ -675,6 +687,17 @@ function validateConfig(raw) {
675
687
  };
676
688
  }
677
689
 
690
+ // Account management sub-config
691
+ if (raw.account_management && typeof raw.account_management === "object") {
692
+ const am = /** @type {Record<string, unknown>} */ (raw.account_management);
693
+ config.account_management = {
694
+ proactive_disabled:
695
+ typeof am.proactive_disabled === "boolean"
696
+ ? am.proactive_disabled
697
+ : DEFAULT_CONFIG.account_management.proactive_disabled,
698
+ };
699
+ }
700
+
678
701
  return config;
679
702
  }
680
703
 
@@ -761,6 +784,16 @@ function applyEnvOverrides(config) {
761
784
  config.adaptive_context.enabled = false;
762
785
  }
763
786
 
787
+ // Account management: env override for proactive penalties / predictive switch.
788
+ // Set to 1/true to disable all proactive account management (matches default).
789
+ // Set to 0/false to re-enable the legacy proactive behavior.
790
+ if (env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED === "1" || env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED === "true") {
791
+ config.account_management.proactive_disabled = true;
792
+ }
793
+ if (env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED === "0" || env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED === "false") {
794
+ config.account_management.proactive_disabled = false;
795
+ }
796
+
764
797
  return config;
765
798
  }
766
799
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [