opencode-anthropic-fix 0.0.47 → 0.0.49

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/README.md +2 -2
  2. package/index.mjs +205 -75
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,7 +42,7 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
42
42
  - **Health scoring** — tracks account reliability and prefers healthy accounts
43
43
  - **Standalone CLI** — manage accounts without opening OpenCode
44
44
  - **Configurable strategies** — sticky, round-robin, or hybrid account selection
45
- - **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code
45
+ - **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code. System prompt cache scoping follows the real CC's three-path `splitSysPromptPrefix()` architecture (boundary/global/org) with exact marker detection and scope-aware `cache_control` generation
46
46
  - **OAuth endpoint fingerprint parity** — matches the real CLI's bundled axios 1.13.6 HTTP client signature (`Accept`, `User-Agent`, `Content-Type`) on all OAuth token endpoint calls, required since 2026-03-21 server-side enforcement
47
47
  - **Billing header fingerprint parity** — `cc_version` suffix uses the real CLI's 3-char fingerprint hash (SHA-256 of salt + first user message chars + version), `cch` matches the Bun native client attestation placeholder, and `X-Claude-Code-Session-Id` header is sent on all requests
48
48
  - **Adaptive thinking for Opus/Sonnet 4.6** — automatically normalizes thinking to `{type: "adaptive"}` for supported models, with `effort-2025-11-24` beta
@@ -52,7 +52,7 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
52
52
  - **Files API integration** — upload, list, download, and manage files via `/anthropic files` with endpoint/content-scoped `files-api-2025-04-14` beta injection
53
53
  - **Code execution support** — available via explicit custom beta opt-in (`code-execution-2025-08-25`), not auto-enabled
54
54
  - **Deep QA conformance** — 680+ tests across 26 test files, including 40 regression tests validating every header, body field, OAuth parameter, beta flag, system prompt block, response handling path, and telemetry schema against the [reverse-engineering doc](docs/claude-code-reverse-engineering.md)
55
- - **Prompt caching with 1h TTL** — extended cache TTL on system prompt blocks, auto-disabled if API rejects; cache hit rate tracking with configurable warning threshold
55
+ - **Prompt caching with 1h TTL** — extended cache TTL on system prompt blocks using scope-correct `cache_control` (`'org'` for default path, `'global'` for boundary path), auto-disabled if API rejects; cache hit rate tracking with configurable warning threshold
56
56
  - **API preconnect** — fire-and-forget HEAD request on init to pre-warm TCP+TLS connection, skipped when proxy/mTLS detected
57
57
  - **8K default output cap** — limits `max_tokens` to 8K by default, auto-escalates to 64K after output truncation, resets after one turn
58
58
  - **Context overflow auto-recovery** — parses structured `prompt_too_long` errors to auto-reduce `max_tokens` before falling back to message trimming
package/index.mjs CHANGED
@@ -151,6 +151,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
151
151
  const getIdleRefreshWindowMs = () => config.idle_refresh.window_minutes * 60 * 1000;
152
152
  const getIdleRefreshMinIntervalMs = () => config.idle_refresh.min_interval_minutes * 60 * 1000;
153
153
 
154
+ /**
155
+ * Previous state of all anthropic-ratelimit-unified-* headers.
156
+ * Used to detect changes and emit toasts when status values transition.
157
+ * Keys mirror the header names (minus the "anthropic-ratelimit-unified-" prefix).
158
+ * @type {Record<string, string | null>}
159
+ */
160
+ const previousUnifiedStatus = {};
161
+
154
162
  // Willow Mode: detect inactivity and suggest context reset.
155
163
  // Named after the willow tree — when idle, the session "droops" and a gentle
156
164
  // nudge suggests starting fresh rather than accumulating stale context.
@@ -2948,6 +2956,40 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2948
2956
  sessionMetrics.lastQuota.overageReason = overageReason;
2949
2957
  }
2950
2958
 
2959
+ // Detect changes in any anthropic-ratelimit-unified-* status headers and toast
2960
+ if (!config.toasts?.quiet) {
2961
+ /** @type {Array<[string, string | null]>} header-suffix → current value */
2962
+ const unifiedStatusHeaders = [
2963
+ ["status", overallStatus],
2964
+ ["representative-claim", representativeClaim],
2965
+ ["fallback", fallbackStatus],
2966
+ ["fallback-percentage", fallbackPct],
2967
+ ["overage-status", overageStatus],
2968
+ ["overage-disabled-reason", overageReason],
2969
+ ];
2970
+ // Add per-window status headers
2971
+ for (const win of RATE_LIMIT_WINDOWS) {
2972
+ unifiedStatusHeaders.push([
2973
+ `${win.key}-status`,
2974
+ response.headers.get(`anthropic-ratelimit-unified-${win.key}-status`),
2975
+ ]);
2976
+ }
2977
+
2978
+ for (const [key, current] of unifiedStatusHeaders) {
2979
+ if (current == null) continue; // header absent — skip
2980
+ const prev = previousUnifiedStatus[key];
2981
+ if (prev !== undefined && prev !== current) {
2982
+ // Value changed — emit a toast
2983
+ const label = key.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
2984
+ toast(`Quota ${label}: ${prev ?? "—"} → ${current}`, "info", {
2985
+ debounceKey: `unified-status-${key}`,
2986
+ }).catch(() => {});
2987
+ debugLog("anthropic-ratelimit-unified status change", { key, prev, current });
2988
+ }
2989
+ previousUnifiedStatus[key] = current;
2990
+ }
2991
+ }
2992
+
2951
2993
  // Back-compat: also update tokens/requests/inputTokens from the highest window
2952
2994
  // so existing code that reads these fields still works
2953
2995
  if (maxUtilization > 0) {
@@ -5743,6 +5785,147 @@ function getCacheControlForPolicy(cachePolicy) {
5743
5785
  return { type: "ephemeral", ttl: cachePolicy.ttl };
5744
5786
  }
5745
5787
 
5788
+ /**
5789
+ * Determine the identity string prefix, matching real CC's getCLISyspromptPrefix().
5790
+ * Real CC selects based on isNonInteractive + hasAppendSystemPrompt flags.
5791
+ * OpenCode is always interactive CLI, so DEFAULT_PREFIX is almost always correct.
5792
+ * We check for Agent SDK signals from the environment to match non-interactive cases.
5793
+ *
5794
+ * @returns {string}
5795
+ */
5796
+ function getCLISyspromptPrefix() {
5797
+ // Agent SDK preset: when running within the Claude Agent SDK with CC preset
5798
+ if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
5799
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "";
5800
+ if (entrypoint === "agent-sdk" || entrypoint === "sdk") {
5801
+ return "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.";
5802
+ }
5803
+ }
5804
+ // Non-interactive agent without CC preset
5805
+ if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && !isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
5806
+ return "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
5807
+ }
5808
+ return CLAUDE_CODE_IDENTITY_STRING;
5809
+ }
5810
+
5811
+ /**
5812
+ * Compute the cache_control object for a given cache scope and policy.
5813
+ * Mirrors real CC getCacheControl() (src/services/api/claude.ts:358-374).
5814
+ *
5815
+ * Real CC behavior:
5816
+ * - scope 'global' → {type: 'ephemeral', scope: 'global', ttl?: '1h'}
5817
+ * - scope 'org' → {type: 'ephemeral', ttl?: '1h'} (org is internal, NOT on wire)
5818
+ * - scope null → block gets NO cache_control at all (caller should omit it)
5819
+ *
5820
+ * @param {'global' | 'org' | null} cacheScope
5821
+ * @param {{ttl: string, ttl_supported: boolean}} cachePolicy
5822
+ * @returns {{type: string, ttl?: string, scope?: string} | null} null means "no cache_control"
5823
+ */
5824
+ function getCacheControlForScope(cacheScope, cachePolicy) {
5825
+ if (cacheScope === null) return null; // no cache_control for this block
5826
+
5827
+ const hasTtl = cachePolicy.ttl !== "off" && cachePolicy.ttl_supported !== false;
5828
+ const result = { type: "ephemeral" };
5829
+ if (hasTtl) result.ttl = cachePolicy.ttl;
5830
+ // Only 'global' scope is emitted on the wire; 'org' is internal-only
5831
+ if (cacheScope === "global") result.scope = "global";
5832
+ return result;
5833
+ }
5834
+
5835
+ /**
5836
+ * Split system prompt blocks into structured blocks with cache scoping,
5837
+ * matching real CC splitSysPromptPrefix() (src/utils/api.ts:321-435).
5838
+ *
5839
+ * Real CC has 3 paths that produce 2 distinct wire formats:
5840
+ *
5841
+ * Path A (tool-based cache): skipGlobalCacheForSystemPrompt=true
5842
+ * - billing → cacheScope: null
5843
+ * - identity → cacheScope: 'org'
5844
+ * - rest (joined) → cacheScope: 'org'
5845
+ * Wire result: identical to Path C (org → ephemeral without scope field)
5846
+ *
5847
+ * Path B (boundary mode): shouldUseGlobalCacheScope() && boundary marker found
5848
+ * - billing → cacheScope: null
5849
+ * - identity → cacheScope: null (NO cache_control in boundary mode!)
5850
+ * - static blocks (before boundary, joined) → cacheScope: 'global'
5851
+ * - dynamic blocks (after boundary, joined) → cacheScope: null
5852
+ *
5853
+ * Path C (fallback): no global cache feature or no boundary
5854
+ * - billing → cacheScope: null
5855
+ * - identity → cacheScope: 'org'
5856
+ * - rest (joined) → cacheScope: 'org'
5857
+ *
5858
+ * @param {Array<{text: string}>} blocks - Already sanitized/filtered text blocks
5859
+ * @param {string | undefined} attributionHeader - The billing header text (or undefined)
5860
+ * @param {string} identityString - The identity prefix string
5861
+ * @param {boolean} useBoundaryMode - Whether to use Path B (global cache with boundary)
5862
+ * @returns {Array<{text: string, cacheScope: 'global' | 'org' | null}>}
5863
+ */
5864
+ function splitSysPromptPrefix(blocks, attributionHeader, identityString, useBoundaryMode) {
5865
+ // Separate known blocks from rest, matching real CC's parsing loop
5866
+ const rest = [];
5867
+ for (const block of blocks) {
5868
+ if (!block.text) continue;
5869
+ // Skip if it's a billing header or identity string (already extracted)
5870
+ if (block.text.startsWith("x-anthropic-billing-header:")) continue;
5871
+ if (KNOWN_IDENTITY_STRINGS.has(block.text)) continue;
5872
+ // Skip the boundary marker itself (real CC skips it in Path A, processes it in Path B)
5873
+ if (block.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
5874
+ rest.push(block.text);
5875
+ }
5876
+
5877
+ // ====================================================================
5878
+ // Path B: Global cache with boundary marker
5879
+ // Real CC (utils/api.ts:219-262): when shouldUseGlobalCacheScope() &&
5880
+ // boundary marker is found in the system prompt array.
5881
+ // ====================================================================
5882
+ if (useBoundaryMode) {
5883
+ // Find boundary marker in the ORIGINAL block array (before filtering)
5884
+ const boundaryIndex = blocks.findIndex((b) => b.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY);
5885
+
5886
+ if (boundaryIndex !== -1) {
5887
+ // Classify blocks as static (before boundary) or dynamic (after boundary)
5888
+ const staticBlocks = [];
5889
+ const dynamicBlocks = [];
5890
+ for (let i = 0; i < blocks.length; i++) {
5891
+ const text = blocks[i].text;
5892
+ if (!text) continue;
5893
+ if (text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
5894
+ if (text.startsWith("x-anthropic-billing-header:")) continue;
5895
+ if (KNOWN_IDENTITY_STRINGS.has(text)) continue;
5896
+ if (i < boundaryIndex) {
5897
+ staticBlocks.push(text);
5898
+ } else {
5899
+ dynamicBlocks.push(text);
5900
+ }
5901
+ }
5902
+
5903
+ const result = [];
5904
+ if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
5905
+ // Identity: cacheScope null in boundary mode (real CC behavior)
5906
+ result.push({ text: identityString, cacheScope: null });
5907
+ const staticJoined = staticBlocks.join("\n\n");
5908
+ if (staticJoined) result.push({ text: staticJoined, cacheScope: "global" });
5909
+ const dynamicJoined = dynamicBlocks.join("\n\n");
5910
+ if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null });
5911
+ return result;
5912
+ }
5913
+ // Boundary marker not found — fall through to Path C
5914
+ }
5915
+
5916
+ // ====================================================================
5917
+ // Path C (fallback) / Path A (tool-based): no boundary or no global cache
5918
+ // Real CC (utils/api.ts:264-289): identity and rest get cacheScope 'org'
5919
+ // Path A produces identical wire output to Path C.
5920
+ // ====================================================================
5921
+ const result = [];
5922
+ if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
5923
+ result.push({ text: identityString, cacheScope: "org" });
5924
+ const restJoined = rest.join("\n\n");
5925
+ if (restJoined) result.push({ text: restJoined, cacheScope: "org" });
5926
+ return result;
5927
+ }
5928
+
5746
5929
  /**
5747
5930
  * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
5748
5931
  * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
@@ -5766,91 +5949,38 @@ function buildSystemPromptBlocks(system, signature) {
5766
5949
  return sanitized;
5767
5950
  }
5768
5951
 
5769
- const filtered = sanitized.filter(
5770
- (item) => !item.text.startsWith("x-anthropic-billing-header:") && !KNOWN_IDENTITY_STRINGS.has(item.text),
5771
- );
5772
-
5773
- const blocks = [];
5952
+ // Build attribution header
5774
5953
  const billingHeader = buildAnthropicBillingHeader(
5775
5954
  signature.claudeCliVersion,
5776
5955
  signature.firstUserMessage,
5777
5956
  signature.provider,
5778
5957
  );
5779
- if (billingHeader) {
5780
- // Billing header: no cache_control (null scope — never cached)
5781
- blocks.push({ type: "text", text: billingHeader });
5782
- }
5783
5958
 
5784
- // Compute cache_control once — used for identity block AND filtered blocks.
5785
- // TTL must be non-decreasing across tools → system → messages, so all cached
5786
- // system blocks must share the same TTL to avoid "1h after 5m" API rejection.
5787
- const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
5788
- const hasTtl = effectiveCachePolicy.ttl !== "off" && effectiveCachePolicy.ttl_supported !== false;
5789
-
5790
- // CC v2.1.90 cache_control scoping (verified against bundle WQ() function):
5791
- // - scope "global" is only emitted for static pre-boundary blocks
5792
- // - scope "org" is internal-only, NEVER emitted on the wire
5793
- // - identity block gets no cache_control in boundary mode, or {type:"ephemeral"} in fallback
5794
- const baseCacheControl = hasTtl ? { type: "ephemeral", ttl: effectiveCachePolicy.ttl } : { type: "ephemeral" };
5795
- const globalCacheControl = hasTtl
5796
- ? { type: "ephemeral", scope: "global", ttl: effectiveCachePolicy.ttl }
5797
- : { type: "ephemeral", scope: "global" };
5798
-
5799
- // Identity block: per CC v2.1.90, gets {type:"ephemeral"} with NO scope field.
5800
- // In boundary mode CC assigns cacheScope:null (no cache_control at all), but
5801
- // we always include it for better cache hit rates on the proxy side.
5802
- blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: baseCacheControl });
5803
-
5804
- // Filtered blocks: keep as-is, with optional static/dynamic boundary marker
5805
- if (filtered.length > 0) {
5806
- const useBoundary =
5807
- signature.cachePolicy?.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
5808
-
5809
- if (useBoundary) {
5810
- // Heuristic: treat first half as "static" (tool defs, instructions)
5811
- // and second half as "dynamic" (env info, memory, etc.)
5812
- // Find a split point: look for blocks containing environment/date/CWD info
5813
- // as the boundary between static and dynamic.
5814
- const splitIndex = filtered.findIndex((block) => {
5815
- const text = block.text.toLowerCase();
5816
- return (
5817
- text.includes("working directory") ||
5818
- text.includes("today's date") ||
5819
- text.includes("current date") ||
5820
- text.includes("environment") ||
5821
- text.includes("platform:")
5822
- );
5823
- });
5959
+ // Select the identity string (matches real CC getCLISyspromptPrefix())
5960
+ const identityString = getCLISyspromptPrefix();
5824
5961
 
5825
- const effectiveSplit = splitIndex > 0 ? splitIndex : Math.ceil(filtered.length / 2);
5962
+ // Determine cache policy
5963
+ const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
5826
5964
 
5827
- // Static blocks (before boundary) get global-scope cache_control
5828
- for (let i = 0; i < effectiveSplit; i++) {
5829
- blocks.push({ ...filtered[i], cache_control: globalCacheControl });
5830
- }
5965
+ // Determine if we should use boundary mode (Path B)
5966
+ // Real CC: shouldUseGlobalCacheScope() is a GrowthBook feature flag.
5967
+ // We simulate it via config: boundary_marker=true or CLAUDE_CODE_FORCE_GLOBAL_CACHE=1.
5968
+ const useBoundaryMode =
5969
+ effectiveCachePolicy.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
5831
5970
 
5832
- // Boundary marker
5833
- blocks.push({ type: "text", text: SYSTEM_PROMPT_DYNAMIC_BOUNDARY });
5971
+ // Run the real CC splitSysPromptPrefix algorithm to get blocks with cacheScope
5972
+ const scopedBlocks = splitSysPromptPrefix(sanitized, billingHeader || undefined, identityString, useBoundaryMode);
5834
5973
 
5835
- // Dynamic blocks (after boundary) get NO cache_control
5836
- for (let i = effectiveSplit; i < filtered.length; i++) {
5837
- const { cache_control: _cc, ...rest } = filtered[i];
5838
- blocks.push(rest);
5839
- }
5840
- } else {
5841
- // Original behavior: only last block gets cache_control.
5842
- // Strip any upstream cache_control from intermediate blocks to prevent
5843
- // TTL ordering violations (e.g., upstream 5m followed by our 1h).
5844
- for (let i = 0; i < filtered.length - 1; i++) {
5845
- const { cache_control: _cc, ...rest } = filtered[i];
5846
- blocks.push(rest);
5847
- }
5848
- const lastFiltered = filtered[filtered.length - 1];
5849
- blocks.push({ ...lastFiltered, cache_control: baseCacheControl });
5850
- }
5851
- }
5852
-
5853
- return blocks;
5974
+ // Convert scoped blocks to wire format using getCacheControlForScope
5975
+ // (mirrors real CC buildSystemPromptBlocks → map + getCacheControl)
5976
+ return scopedBlocks.map((block) => {
5977
+ const cc = getCacheControlForScope(block.cacheScope, effectiveCachePolicy);
5978
+ return {
5979
+ type: "text",
5980
+ text: block.text,
5981
+ ...(cc !== null && { cache_control: cc }),
5982
+ };
5983
+ });
5854
5984
  }
5855
5985
 
5856
5986
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [