opencode-anthropic-fix 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -365,11 +365,12 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
365
365
 
366
366
  Control how the plugin picks which account to use for each request.
367
367
 
368
- | Strategy | Behavior | Best For |
369
- | ---------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
370
- | **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, predictable behavior, full feature compatibility |
371
- | **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts (see [limitations](#round-robin-limitations)) |
372
- | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
368
+ | Strategy | Behavior | Best For |
369
+ | ---------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
370
+ | **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, predictable behavior, full feature compatibility |
371
+ | **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts (see [limitations](#round-robin-limitations)) |
372
+ | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
373
+ | **`single`** | Use one account only; stop and alert when it fails or is rate-limited (never auto-switches) | Simple multi-account setups (e.g. work vs personal) where requests must never mix accounts |
373
374
 
374
375
  ### Change Strategy
375
376
 
@@ -413,7 +414,7 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
413
414
 
414
415
  ```jsonc
415
416
  {
416
- // Account selection strategy: "sticky" | "round-robin" | "hybrid"
417
+ // Account selection strategy: "sticky" | "round-robin" | "hybrid" | "single"
417
418
  "account_selection_strategy": "sticky",
418
419
 
419
420
  // Seconds before consecutive failure count resets (60-7200)
package/cli.mjs CHANGED
@@ -1153,6 +1153,7 @@ export async function cmdStrategy(arg) {
1153
1153
  sticky: "Stay on one account until it fails or is rate-limited",
1154
1154
  "round-robin": "Rotate through accounts on every request",
1155
1155
  hybrid: "Prefer healthy accounts, rotate when degraded",
1156
+ single: "Use one account only; stop and alert when it fails or is rate-limited",
1156
1157
  };
1157
1158
 
1158
1159
  for (const s of VALID_STRATEGIES) {
package/index.mjs CHANGED
@@ -15,15 +15,11 @@ import {
15
15
  FALLBACK_CLAUDE_CLI_VERSION,
16
16
  CLAUDE_CODE_NPM_LATEST_URL,
17
17
  CLAUDE_CODE_BUILD_TIME,
18
- EXPERIMENTAL_BETA_FLAGS,
19
- BETA_SHORTCUTS,
20
18
  resolveBetaShortcut,
21
- buildExtendedUserAgent,
22
19
  } from "./lib/request-headers.mjs";
23
- import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
20
+ import { loadConfig, loadConfigFresh, saveConfig, getConfigDir } from "./lib/config.mjs";
24
21
  import { loadContextHintDisabledFlag, saveContextHintDisabledFlag } from "./lib/context-hint-persist.mjs";
25
22
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
26
- import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
27
23
  import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
28
24
  import {
29
25
  isAccountSpecificError,
@@ -51,7 +47,7 @@ import {
51
47
  stripMcpPrefixFromParsedEvent,
52
48
  } from "./lib/mimicry/response-stream.mjs";
53
49
  import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
54
- import { isFalsyEnv, isTruthyEnv } from "./lib/env.mjs";
50
+ import { isTruthyEnv } from "./lib/env.mjs";
55
51
  import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
56
52
  import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
57
53
  import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
@@ -83,13 +79,11 @@ import {
83
79
  SUBAGENT_CC_ANCHOR,
84
80
  } from "./lib/mimicry/system-prompt.mjs";
85
81
  import {
86
- CLAUDE_3_MODEL_RE,
87
82
  hasOneMillionContext,
88
83
  isEligibleFor1MContext,
89
84
  isOpus46Model,
90
85
  isOpus47Model,
91
86
  isOpus48Model,
92
- isSonnet46Model,
93
87
  } from "./lib/mimicry/models.mjs";
94
88
 
95
89
  export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/models.mjs";
@@ -241,7 +235,7 @@ async function runHaikuSessionSummarize(
241
235
  }
242
236
  }
243
237
 
244
- export async function AnthropicAuthPlugin({ client, project, directory, worktree, serverUrl, $ }) {
238
+ export async function AnthropicAuthPlugin({ client }) {
245
239
  const config = loadConfig();
246
240
  _pluginConfig = config; // expose to module-level functions (cache stats, response headers)
247
241
  // QA fix H6: read emulation settings live from config instead of stale const capture
@@ -1191,7 +1185,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1191
1185
  config.toasts.quiet = enabled;
1192
1186
  },
1193
1187
  strategy: () => {
1194
- const valid = ["sticky", "round-robin", "hybrid"];
1188
+ const valid = ["sticky", "round-robin", "hybrid", "single"];
1195
1189
  if (valid.includes(value)) {
1196
1190
  saveConfig({ account_selection_strategy: value });
1197
1191
  strategyState.manualOverride = true;
@@ -5576,7 +5570,6 @@ process.once("beforeExit", _beforeExitHandler);
5576
5570
  // The compiled Bun binary computes cch dynamically: xxHash64(body, seed) & 0xFFFFF.
5577
5571
  // Captured real CC v2.1.107 request shows cch=6d00f (5-hex-char, 20-bit masked hash).
5578
5572
  // Seed extracted from binary: 0x6E52736AC806831E (unchanged since v2.1.96).
5579
- const CCH_SEED = 0x6e52736ac806831en; // BigInt — Attestation.zig seed
5580
5573
 
5581
5574
  /** @type {null | ((buf: Uint8Array, seed: bigint) => bigint)} */
5582
5575
  let _xxh64Raw = null;
@@ -5584,22 +5577,6 @@ const _xxhashReady = xxhashInit().then((h) => {
5584
5577
  _xxh64Raw = h.h64Raw;
5585
5578
  });
5586
5579
 
5587
- /**
5588
- * Compute and replace the cch=00000 placeholder in the serialized body with
5589
- * xxHash64(body, seed) & 0xFFFFF, matching the Bun binary's native attestation.
5590
- * @param {string} body - Serialized JSON body
5591
- * @returns {Promise<string>} Body with cch replaced
5592
- */
5593
- async function computeAndReplaceCCH(body) {
5594
- if (typeof body !== "string" || !body.includes("cch=00000")) return body;
5595
- await _xxhashReady;
5596
- if (!_xxh64Raw) return body; // fallback: send as-is if wasm failed to load
5597
- const bodyBytes = Buffer.from(body, "utf-8");
5598
- const hash = _xxh64Raw(bodyBytes, CCH_SEED);
5599
- const cch = (hash & 0xfffffn).toString(16).padStart(5, "0");
5600
- return body.replace("cch=00000", `cch=${cch}`);
5601
- }
5602
-
5603
5580
  /**
5604
5581
  * Extract the text content of the first user message for billing hash computation.
5605
5582
  * @param {any[] | undefined} messages
@@ -5700,13 +5677,6 @@ function shouldDebugSystemPrompt() {
5700
5677
  * @returns {Record<string, string>}
5701
5678
  */
5702
5679
 
5703
- /**
5704
- * @returns {string}
5705
- */
5706
- function getClaudeEntrypoint() {
5707
- return process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
5708
- }
5709
-
5710
5680
  /**
5711
5681
  * @param {string | undefined} model
5712
5682
  * @returns {string | undefined}
@@ -5733,15 +5703,6 @@ function logTransformedSystemPrompt(body) {
5733
5703
  * @returns {boolean}
5734
5704
  */
5735
5705
 
5736
- /**
5737
- * @param {string | undefined} body
5738
- * @returns {boolean}
5739
- */
5740
- function supportsThinking(model) {
5741
- if (!model) return true;
5742
- return /claude|sonnet|opus|haiku/i.test(model);
5743
- }
5744
-
5745
5706
  /**
5746
5707
  * @param {string | undefined} body
5747
5708
  * @returns {boolean}
@@ -5830,19 +5791,6 @@ function getAccountIdentifier(account) {
5830
5791
  * @returns {{user_id: string}}
5831
5792
  */
5832
5793
 
5833
- /**
5834
- * Return the cache_control object appropriate for the given cache policy.
5835
- * @param {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | undefined} cachePolicy
5836
- * @returns {{type: string, ttl?: string}}
5837
- */
5838
- function getCacheControlForPolicy(cachePolicy) {
5839
- if (!cachePolicy) return { type: "ephemeral" };
5840
- if (cachePolicy.ttl === "off" || cachePolicy.ttl_supported === false) {
5841
- return { type: "ephemeral" };
5842
- }
5843
- return { type: "ephemeral", ttl: cachePolicy.ttl };
5844
- }
5845
-
5846
5794
  /**
5847
5795
  * @param {string} incomingBeta
5848
5796
  * @param {boolean} signatureEnabled
@@ -5866,18 +5814,6 @@ function getCacheControlForPolicy(cachePolicy) {
5866
5814
 
5867
5815
  /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
5868
5816
 
5869
- /**
5870
- * Map budgetTokens to an effort level.
5871
- * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
5872
- * @param {number} budgetTokens
5873
- * @returns {ThinkingEffort}
5874
- */
5875
- function budgetTokensToEffort(budgetTokens) {
5876
- if (budgetTokens <= 1024) return "low";
5877
- if (budgetTokens <= 8000) return "medium";
5878
- return "high";
5879
- }
5880
-
5881
5817
  // QA fix L3: budgetTokensToEffort() removed — dead code, never called
5882
5818
  // QA fix L4: isValidEffort() removed — dead code, never called
5883
5819
 
@@ -6068,19 +6004,6 @@ function isEventStreamResponse(response) {
6068
6004
  return contentType.toLowerCase().includes("text/event-stream");
6069
6005
  }
6070
6006
 
6071
- /**
6072
- * Build user-facing switch reason text for account-specific errors.
6073
- * @param {number} status
6074
- * @param {import('./lib/backoff.mjs').RateLimitReason} reason
6075
- * @returns {string}
6076
- */
6077
- function formatSwitchReason(status, reason) {
6078
- if (reason === "AUTH_FAILED") return "auth failed";
6079
- if (status === 403 && reason === "QUOTA_EXHAUSTED") return "permission denied";
6080
- if (reason === "QUOTA_EXHAUSTED") return "quota exhausted";
6081
- return "rate-limited";
6082
- }
6083
-
6084
6007
  // ---------------------------------------------------------------------------
6085
6008
  // Token refresh (per-account)
6086
6009
  // ---------------------------------------------------------------------------
@@ -6153,7 +6076,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
6153
6076
  * @returns {Promise<string>} The new access token
6154
6077
  * @throws {Error} If refresh fails
6155
6078
  */
6156
- async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
6079
+ async function refreshAccountToken(account, client, _source = "foreground", { onTokensUpdated } = {}) {
6157
6080
  // CC-sourced accounts must NEVER enter the OAuth HTTP refresh flow.
6158
6081
  // Instead, re-read credentials from the CC source. If they're still
6159
6082
  // expired, let the caller handle it (the account will be skipped).
package/lib/backoff.mjs CHANGED
@@ -134,7 +134,7 @@ function extractErrorSignals(body) {
134
134
  * @returns {boolean}
135
135
  */
136
136
  function bodyHasAccountError(body) {
137
- const { errorType, message, text } = extractErrorSignals(body);
137
+ const { errorType, message } = extractErrorSignals(body);
138
138
 
139
139
  const typeSignals = [
140
140
  "rate_limit",
@@ -84,7 +84,7 @@ function readKeychainService(service) {
84
84
  }).trim();
85
85
 
86
86
  return parseCCCredentialData(raw, "cc-keychain", `keychain:${service}`);
87
- } catch (err) {
87
+ } catch {
88
88
  // Exit code 36 = user denied, 44 = not found, 128 = killed
89
89
  return null;
90
90
  }
package/lib/config.mjs CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
5
 
6
6
  /**
7
- * @typedef {'sticky' | 'round-robin' | 'hybrid'} AccountSelectionStrategy
7
+ * @typedef {'sticky' | 'round-robin' | 'hybrid' | 'single'} AccountSelectionStrategy
8
8
  */
9
9
 
10
10
  /**
@@ -449,7 +449,7 @@ export const DEFAULT_CONFIG = {
449
449
  },
450
450
  };
451
451
 
452
- export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
452
+ export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid", "single"];
453
453
 
454
454
  /** OpenCode's OAuth client ID for Anthropic console auth flows. */
455
455
  export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -138,6 +138,13 @@ describe("loadConfig", () => {
138
138
  expect(config.account_selection_strategy).toBe("sticky");
139
139
  });
140
140
 
141
+ it("accepts 'single' strategy from config file", () => {
142
+ existsSync.mockReturnValue(true);
143
+ readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "single" }));
144
+ const config = loadConfig();
145
+ expect(config.account_selection_strategy).toBe("single");
146
+ });
147
+
141
148
  it("ignores invalid strategy", () => {
142
149
  existsSync.mockReturnValue(true);
143
150
  readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "invalid" }));
@@ -177,8 +177,6 @@ export function buildAnthropicBetaHeader(
177
177
  return mergedBetas.join(",");
178
178
  }
179
179
 
180
- const nonInteractive = isNonInteractiveMode();
181
- const haiku = isHaikuModel(model);
182
180
  const isRoundRobin = strategy === "round-robin";
183
181
  const te = tokenEconomy || {};
184
182
 
@@ -66,8 +66,6 @@ export const CORE_TOOL_NAMES = new Set([
66
66
  export function transformRequestBody(body, signature, runtime, betaHeader, config) {
67
67
  if (!body || typeof body !== "string") return body;
68
68
 
69
- const TOOL_PREFIX = "mcp_";
70
-
71
69
  try {
72
70
  const parsed = JSON.parse(body);
73
71
  // Diagnostic: log the incoming model + thinking/speed shape so model-routing
package/lib/rotation.mjs CHANGED
@@ -259,6 +259,22 @@ export function selectAccount(candidates, strategy, currentIndex, healthTracker,
259
259
  return next ? { index: next.index, cursor: cursor + 1 } : null;
260
260
  }
261
261
 
262
+ case "single": {
263
+ // Single-account mode: use exactly one account and never auto-rotate.
264
+ // Stay on the current account while it is available; if it is
265
+ // rate-limited or otherwise unavailable, STOP (return null) so the
266
+ // caller alerts the user instead of silently switching accounts.
267
+ // Auto-resumes once the account becomes available again (e.g. the
268
+ // rate-limit window expires). See issue #20.
269
+ if (currentIndex !== null) {
270
+ const current = available.find((acc) => acc.index === currentIndex);
271
+ return current ? { index: current.index, cursor } : null;
272
+ }
273
+ // No account selected yet — pick the first available as the single account.
274
+ const next = available[cursor % available.length];
275
+ return next ? { index: next.index, cursor: cursor + 1 } : null;
276
+ }
277
+
262
278
  case "hybrid": {
263
279
  const scoredCandidates = available
264
280
  .filter((acc) => healthTracker.isUsable(acc.index) && tokenTracker.hasTokens(acc.index))
@@ -337,6 +337,52 @@ describe("selectAccount", () => {
337
337
  });
338
338
  });
339
339
 
340
+ // Single strategy (issue #20)
341
+ describe("single strategy", () => {
342
+ it("selects first available account when no current index", () => {
343
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
344
+ const result = selectAccount(candidates, "single", null, healthTracker, tokenTracker, 0);
345
+ expect(result?.index).toBe(0);
346
+ });
347
+
348
+ it("stays on the current account when it is available", () => {
349
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
350
+ const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
351
+ expect(result?.index).toBe(0);
352
+ expect(result?.cursor).toBe(0);
353
+ });
354
+
355
+ it("returns null when current is rate-limited (does NOT switch)", () => {
356
+ const candidates = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
357
+ const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
358
+ // Unlike sticky, single must NOT fall through to account 1.
359
+ expect(result).toBeNull();
360
+ });
361
+
362
+ it("returns null when the current account is gone (disabled/removed)", () => {
363
+ const candidates = [makeCandidate({ index: 1 })];
364
+ const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
365
+ expect(result).toBeNull();
366
+ });
367
+
368
+ it("ignores health score and stays on current", () => {
369
+ for (let i = 0; i < 5; i++) {
370
+ healthTracker.recordFailure(0);
371
+ }
372
+ const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
373
+ const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
374
+ expect(result?.index).toBe(0);
375
+ });
376
+
377
+ it("auto-resumes the current account once its rate-limit clears", () => {
378
+ const limited = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
379
+ expect(selectAccount(limited, "single", 0, healthTracker, tokenTracker, 0)).toBeNull();
380
+ const cleared = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
381
+ const result = selectAccount(cleared, "single", 0, healthTracker, tokenTracker, 0);
382
+ expect(result?.index).toBe(0);
383
+ });
384
+ });
385
+
340
386
  // Default/unknown strategy
341
387
  it("falls back to first available for unknown strategy", () => {
342
388
  const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",