opencode-anthropic-fix 0.0.18 → 0.0.20

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
@@ -43,8 +43,9 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
43
43
  - **Standalone CLI** — manage accounts without opening OpenCode
44
44
  - **Configurable strategies** — sticky, round-robin, or hybrid account selection
45
45
  - **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code
46
- - **Adaptive thinking for Opus 4.6** — automatically uses `adaptive-thinking-2026-01-28` and maps `budgetTokens` to effort levels (`low`/`medium`/`high`/`max`)
46
+ - **Adaptive thinking for Opus 4.6** — automatically uses `adaptive-thinking-2026-01-28` and maps `budgetTokens` to effort levels (`low`/`medium`/`high`)
47
47
  - **1M context limit override** — patches `model.limit.context` so OpenCode compacts at the right threshold while `models.dev` catches up
48
+ - **Runtime config + custom betas** — `/anthropic set`, `/anthropic config`, and `/anthropic betas` slash commands for live feature toggling without restarting OpenCode
48
49
 
49
50
  ## Installation
50
51
 
@@ -231,20 +232,65 @@ Most commands have short aliases: `ln`, `lo`, `ra`, `rf`, `ls`, `st`, `sw`, `en`
231
232
 
232
233
  ## Slash Commands in OpenCode
233
234
 
234
- The plugin also registers a built-in `/anthropic` slash command so you can manage accounts without leaving OpenCode.
235
+ The plugin registers a built-in `/anthropic` slash command for account management, feature toggles, and custom beta headers — all without leaving OpenCode.
235
236
 
236
- ### Common examples
237
+ ### Account management
237
238
 
238
239
  ```text
239
- /anthropic # list (default)
240
+ /anthropic # list accounts (default)
240
241
  /anthropic usage # full account list + quota windows
241
242
  /anthropic switch 2
242
243
  /anthropic refresh 1
243
244
  /anthropic logout 2 # revoke tokens and remove account 2
244
245
  /anthropic logout --all # revoke all tokens and clear all accounts
245
- /anthropic strategy hybrid
246
246
  /anthropic stats
247
- /anthropic config
247
+ ```
248
+
249
+ ### Runtime configuration
250
+
251
+ Toggle features live without restarting OpenCode. Changes are persisted to `anthropic-auth.json`.
252
+
253
+ ```text
254
+ /anthropic config # show all current settings
255
+ /anthropic set emulation on # enable/disable Claude signature emulation
256
+ /anthropic set compaction minimal # set prompt compaction mode (minimal/off)
257
+ /anthropic set 1m-context on # enable/disable 1M context limit override
258
+ /anthropic set idle-refresh on # enable/disable idle account refresh
259
+ /anthropic set strategy round-robin # change account selection strategy
260
+ /anthropic set debug on # enable/disable debug logging
261
+ /anthropic set quiet on # suppress non-error toasts
262
+ ```
263
+
264
+ ### Custom beta headers
265
+
266
+ Add or remove beta flags that get included in every `anthropic-beta` header. Persisted across sessions.
267
+
268
+ ```text
269
+ /anthropic betas # show active betas (auto + custom) with presets
270
+ /anthropic betas add <beta-name> # add a custom beta
271
+ /anthropic betas remove <beta-name> # remove a custom beta
272
+ ```
273
+
274
+ **Available preset betas** (shown by `/anthropic betas`):
275
+
276
+ | Beta | Description |
277
+ | --------------------------------- | ------------------------------------------------- |
278
+ | `prompt-caching-scope-2026-01-05` | Cache control with `scope: "org"` — free perf win |
279
+ | `context-management-2025-06-27` | Server-side auto-summarization when context fills |
280
+ | `structured-outputs-2025-12-15` | Strict JSON schema output enforcement |
281
+ | `tool-examples-2025-10-29` | Input/output examples in tool definitions |
282
+ | `code-execution-2025-08-25` | Python sandbox execution by the model |
283
+ | `files-api-2025-04-14` | Upload once, reuse file ID across requests |
284
+ | `compact-2026-01-12` | Conversation compaction endpoint |
285
+ | `mcp-servers-2025-12-04` | MCP servers in API request payload |
286
+ | `web-search-2025-03-05` | Web search (Vertex/Foundry only) |
287
+
288
+ Example workflow:
289
+
290
+ ```text
291
+ /anthropic betas add prompt-caching-scope-2026-01-05
292
+ /anthropic betas add context-management-2025-06-27
293
+ /anthropic betas # verify
248
294
  ```
249
295
 
250
296
  ### OAuth flows from slash command
@@ -272,11 +318,11 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
272
318
 
273
319
  Control how the plugin picks which account to use for each request.
274
320
 
275
- | Strategy | Behavior | Best For |
276
- | ---------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- |
277
- | **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, or when you want predictable behavior |
278
- | **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts |
279
- | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
321
+ | Strategy | Behavior | Best For |
322
+ | --------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- |
323
+ | **`round-robin`** (default) | Rotate through accounts on every request | Spreading load evenly across accounts |
324
+ | **`sticky`** | Stay on one account until it fails or is rate-limited | Single account, or when you want predictable behavior |
325
+ | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
280
326
 
281
327
  ### Change Strategy
282
328
 
@@ -297,8 +343,8 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
297
343
 
298
344
  ```jsonc
299
345
  {
300
- // Account selection strategy: "sticky" | "round-robin" | "hybrid"
301
- "account_selection_strategy": "sticky",
346
+ // Account selection strategy: "round-robin" | "sticky" | "hybrid"
347
+ "account_selection_strategy": "round-robin",
302
348
 
303
349
  // Seconds before consecutive failure count resets (60-7200)
304
350
  "failure_ttl_seconds": 3600,
@@ -325,8 +371,8 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
325
371
  // Only applied for OAuth (Max Plan) sessions — API key users use the
326
372
  // context-1m-2025-08-07 beta header instead.
327
373
  "override_model_limits": {
328
- // Enable/disable the override
329
- "enabled": true,
374
+ // Enable/disable the override (default: off — enable if you need 1M context)
375
+ "enabled": false,
330
376
  // Context window to inject (tokens). Default: 1_000_000.
331
377
  "context": 1000000,
332
378
  // Max output tokens to inject. 0 = leave the model's default unchanged.
@@ -351,6 +397,9 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
351
397
  "initial_tokens": 50,
352
398
  },
353
399
 
400
+ // Custom beta headers (added to every request via /anthropic betas add)
401
+ "custom_betas": [],
402
+
354
403
  // Toast notification settings
355
404
  "toasts": {
356
405
  // Suppress non-error toasts (account status, switching)
package/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { AccountManager } from "./lib/accounts.mjs";
7
7
  import { authorize, exchange, refreshToken } from "./lib/oauth.mjs";
8
- import { loadConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
8
+ import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
9
9
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
10
10
  import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
11
11
  import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
@@ -103,7 +103,7 @@ async function promptManageAccounts(accountManager) {
103
103
  // Request building helpers (extracted from original fetch interceptor)
104
104
  // ---------------------------------------------------------------------------
105
105
 
106
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.68";
106
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.76";
107
107
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
108
108
  const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
109
109
  const BILLING_HASH_SALT = "59cf53e54c78";
@@ -292,7 +292,10 @@ function supportsThinking(model) {
292
292
  */
293
293
  function isOpus46Model(model) {
294
294
  if (!model) return false;
295
- return /claude-opus-4[._-]6/i.test(model);
295
+ // Match standard IDs (claude-opus-4-6, claude-opus-4.6) and Bedrock ARNs
296
+ // (arn:aws:bedrock:...anthropic.claude-opus-4-6-...).
297
+ // Also match bare "opus-4-6" / "opus-4.6" fragments for non-standard strings.
298
+ return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
296
299
  }
297
300
 
298
301
  /**
@@ -300,7 +303,8 @@ function isOpus46Model(model) {
300
303
  * @returns {boolean}
301
304
  */
302
305
  function hasOneMillionContext(model) {
303
- return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model);
306
+ // Models with explicit 1m suffix, or Opus 4.6 (1M by default since v2.1.75).
307
+ return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model) || isOpus46Model(model);
304
308
  }
305
309
 
306
310
  /**
@@ -655,9 +659,10 @@ function buildSystemPromptBlocks(system, signature, messages) {
655
659
  * @param {boolean} signatureEnabled
656
660
  * @param {string} model
657
661
  * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
662
+ * @param {string[]} [customBetas]
658
663
  * @returns {string}
659
664
  */
660
- function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider) {
665
+ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas) {
661
666
  const incomingBetasList = incomingBeta
662
667
  .split(",")
663
668
  .map((b) => b.trim())
@@ -725,6 +730,10 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
725
730
 
726
731
  betas.push("fine-grained-tool-streaming-2025-05-14");
727
732
 
733
+ if (Array.isArray(customBetas)) {
734
+ betas.push(...customBetas.filter(Boolean));
735
+ }
736
+
728
737
  const mergedBetas = [...new Set([...betas, ...incomingBetasList])];
729
738
  if (provider === "bedrock") {
730
739
  return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
@@ -732,7 +741,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
732
741
  return mergedBetas.join(",");
733
742
  }
734
743
 
735
- /** @typedef {'low' | 'medium' | 'high' | 'max'} ThinkingEffort */
744
+ /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
736
745
 
737
746
  /**
738
747
  * Map budgetTokens to an adaptive thinking effort level.
@@ -743,8 +752,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
743
752
  function budgetTokensToEffort(budgetTokens) {
744
753
  if (budgetTokens <= 1024) return "low";
745
754
  if (budgetTokens <= 8000) return "medium";
746
- if (budgetTokens <= 16000) return "high";
747
- return "max";
755
+ return "high";
748
756
  }
749
757
 
750
758
  /**
@@ -753,7 +761,7 @@ function budgetTokensToEffort(budgetTokens) {
753
761
  * @returns {value is ThinkingEffort}
754
762
  */
755
763
  function isValidEffort(value) {
756
- return value === "low" || value === "medium" || value === "high" || value === "max";
764
+ return value === "low" || value === "medium" || value === "high";
757
765
  }
758
766
 
759
767
  /**
@@ -884,7 +892,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
884
892
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
885
893
  const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
886
894
  const provider = detectProvider(requestUrl);
887
- const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider);
895
+ const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider, signature.customBetas);
888
896
 
889
897
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
890
898
  const bearerToken = authTokenOverride || accessToken;
@@ -1427,10 +1435,14 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
1427
1435
  * @param {import('./lib/accounts.mjs').ManagedAccount} account
1428
1436
  * @param {ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient>} client
1429
1437
  * @param {"foreground" | "idle"} [source]
1438
+ * @param {{ onTokensUpdated?: () => Promise<void> }} [options] - If provided,
1439
+ * called under the cross-process lock after token update to persist rotated
1440
+ * tokens before the lock is released. Omitting means tokens won't be saved
1441
+ * to disk until the caller arranges it (risking the rotation race).
1430
1442
  * @returns {Promise<string>} The new access token
1431
1443
  * @throws {Error} If refresh fails
1432
1444
  */
1433
- async function refreshAccountToken(account, client, source = "foreground") {
1445
+ async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
1434
1446
  const lockResult = await acquireRefreshLock(account.id, {
1435
1447
  timeoutMs: 2_000,
1436
1448
  backoffMs: 60,
@@ -1471,6 +1483,22 @@ async function refreshAccountToken(account, client, source = "foreground") {
1471
1483
  }
1472
1484
  markTokenStateUpdated(account);
1473
1485
 
1486
+ // Persist new tokens to disk BEFORE releasing the cross-process lock.
1487
+ // This is critical: if we release the lock first, another process can
1488
+ // acquire it and read the old (now-rotated) refresh token from disk,
1489
+ // leading to an invalid_grant failure. The debounced requestSaveToDisk()
1490
+ // that callers used previously left a ~1 s window where this race could
1491
+ // (and did) happen.
1492
+ if (onTokensUpdated) {
1493
+ try {
1494
+ await onTokensUpdated();
1495
+ } catch {
1496
+ // Best-effort: in-memory tokens remain valid for this process.
1497
+ // The callback is responsible for scheduling its own fallback
1498
+ // (e.g. a debounced retry) if the synchronous save fails.
1499
+ }
1500
+ }
1501
+
1474
1502
  // Also persist to OpenCode's auth.json for compatibility.
1475
1503
  // This should be best-effort: a persistence hiccup should not invalidate an
1476
1504
  // otherwise successful refresh token exchange.
@@ -1877,6 +1905,140 @@ export async function AnthropicAuthPlugin({ client }) {
1877
1905
  return;
1878
1906
  }
1879
1907
 
1908
+ // /anthropic config — show effective config
1909
+ if (primary === "config") {
1910
+ const fresh = loadConfigFresh();
1911
+ const lines = [
1912
+ "▣ Anthropic Config",
1913
+ "",
1914
+ `strategy: ${fresh.account_selection_strategy}`,
1915
+ `emulation: ${fresh.signature_emulation.enabled ? "on" : "off"}`,
1916
+ `compaction: ${fresh.signature_emulation.prompt_compaction}`,
1917
+ `1m-context: ${fresh.override_model_limits.enabled ? "on" : "off"}`,
1918
+ `idle-refresh: ${fresh.idle_refresh.enabled ? "on" : "off"}`,
1919
+ `debug: ${fresh.debug ? "on" : "off"}`,
1920
+ `quiet: ${fresh.toasts.quiet ? "on" : "off"}`,
1921
+ `custom_betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1922
+ ];
1923
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
1924
+ return;
1925
+ }
1926
+
1927
+ // /anthropic set <key> <value> — toggle features at runtime
1928
+ if (primary === "set") {
1929
+ const key = (args[1] || "").toLowerCase();
1930
+ const value = (args[2] || "").toLowerCase();
1931
+ /** @type {Record<string, () => void>} */
1932
+ const setters = {
1933
+ emulation: () =>
1934
+ saveConfig({ signature_emulation: { enabled: value === "on" || value === "1" || value === "true" } }),
1935
+ compaction: () =>
1936
+ saveConfig({ signature_emulation: { prompt_compaction: value === "off" ? "off" : "minimal" } }),
1937
+ "1m-context": () =>
1938
+ saveConfig({ override_model_limits: { enabled: value === "on" || value === "1" || value === "true" } }),
1939
+ "idle-refresh": () =>
1940
+ saveConfig({ idle_refresh: { enabled: value === "on" || value === "1" || value === "true" } }),
1941
+ debug: () => saveConfig({ debug: value === "on" || value === "1" || value === "true" }),
1942
+ quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
1943
+ strategy: () => {
1944
+ const valid = ["sticky", "round-robin", "hybrid"];
1945
+ if (valid.includes(value)) saveConfig({ account_selection_strategy: value });
1946
+ else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
1947
+ },
1948
+ };
1949
+
1950
+ if (!key || !setters[key]) {
1951
+ const keys = Object.keys(setters).join(", ");
1952
+ await sendCommandMessage(
1953
+ input.sessionID,
1954
+ `▣ Anthropic Set\n\nUsage: /anthropic set <key> <value>\nKeys: ${keys}\nValues: on/off (or specific values for strategy/compaction)`,
1955
+ );
1956
+ return;
1957
+ }
1958
+ if (!value) {
1959
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\nMissing value for "${key}".`);
1960
+ return;
1961
+ }
1962
+ setters[key]();
1963
+ // Reload config into runtime
1964
+ Object.assign(config, loadConfigFresh());
1965
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\n${key} = ${value}`);
1966
+ return;
1967
+ }
1968
+
1969
+ // /anthropic betas [add|remove <beta>] — show/manage custom betas
1970
+ if (primary === "betas") {
1971
+ const action = (args[1] || "").toLowerCase();
1972
+
1973
+ if (!action || action === "list") {
1974
+ const fresh = loadConfigFresh();
1975
+ const lines = [
1976
+ "▣ Anthropic Betas",
1977
+ "",
1978
+ "Preset betas (auto-computed per model/provider):",
1979
+ " oauth-2025-04-20, claude-code-20250219,",
1980
+ " interleaved-thinking-2025-05-14 OR adaptive-thinking-2026-01-28,",
1981
+ " fine-grained-tool-streaming-2025-05-14",
1982
+ "",
1983
+ `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1984
+ "",
1985
+ "Toggleable presets:",
1986
+ " /anthropic betas add structured-outputs-2025-12-15",
1987
+ " /anthropic betas add context-management-2025-06-27",
1988
+ " /anthropic betas add prompt-caching-scope-2026-01-05",
1989
+ " /anthropic betas add tool-examples-2025-10-29",
1990
+ " /anthropic betas add web-search-2025-03-05",
1991
+ " /anthropic betas add code-execution-2025-08-25",
1992
+ " /anthropic betas add compact-2026-01-12",
1993
+ " /anthropic betas add mcp-servers-2025-12-04",
1994
+ " /anthropic betas add files-api-2025-04-14",
1995
+ "",
1996
+ "Remove: /anthropic betas remove <beta>",
1997
+ ];
1998
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
1999
+ return;
2000
+ }
2001
+
2002
+ if (action === "add") {
2003
+ const beta = args[2]?.trim();
2004
+ if (!beta) {
2005
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas add <beta-name>");
2006
+ return;
2007
+ }
2008
+ const fresh = loadConfigFresh();
2009
+ const current = fresh.custom_betas || [];
2010
+ if (current.includes(beta)) {
2011
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" already added.`);
2012
+ return;
2013
+ }
2014
+ saveConfig({ custom_betas: [...current, beta] });
2015
+ Object.assign(config, loadConfigFresh());
2016
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nAdded: ${beta}`);
2017
+ return;
2018
+ }
2019
+
2020
+ if (action === "remove" || action === "rm") {
2021
+ const beta = args[2]?.trim();
2022
+ if (!beta) {
2023
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas remove <beta-name>");
2024
+ return;
2025
+ }
2026
+ const fresh = loadConfigFresh();
2027
+ const current = fresh.custom_betas || [];
2028
+ if (!current.includes(beta)) {
2029
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" not in custom betas.`);
2030
+ return;
2031
+ }
2032
+ saveConfig({ custom_betas: current.filter((b) => b !== beta) });
2033
+ Object.assign(config, loadConfigFresh());
2034
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nRemoved: ${beta}`);
2035
+ return;
2036
+ }
2037
+
2038
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas [add|remove <beta>]");
2039
+ return;
2040
+ }
2041
+
1880
2042
  // Interactive CLI command is not compatible with slash flow.
1881
2043
  if (primary === "manage" || primary === "mg") {
1882
2044
  await sendCommandMessage(
@@ -2011,7 +2173,21 @@ export async function AnthropicAuthPlugin({ client }) {
2011
2173
  const entry = { source, promise: Promise.resolve("") };
2012
2174
  const p = (async () => {
2013
2175
  try {
2014
- return await refreshAccountToken(account, client, source);
2176
+ return await refreshAccountToken(account, client, source, {
2177
+ onTokensUpdated: async () => {
2178
+ try {
2179
+ await accountManager.saveToDisk();
2180
+ } catch {
2181
+ // Synchronous save failed (disk full, permissions, etc.).
2182
+ // Schedule a debounced retry so the rotated token eventually
2183
+ // reaches disk. Another process may hit invalid_grant in the
2184
+ // interim, but its retry-from-disk logic can recover once this
2185
+ // save lands.
2186
+ accountManager.requestSaveToDisk();
2187
+ throw new Error("save failed, debounced retry scheduled");
2188
+ }
2189
+ },
2190
+ });
2015
2191
  } finally {
2016
2192
  if (refreshInFlight.get(key) === entry) {
2017
2193
  refreshInFlight.delete(key);
@@ -2040,7 +2216,6 @@ export async function AnthropicAuthPlugin({ client }) {
2040
2216
  try {
2041
2217
  try {
2042
2218
  await refreshAccountTokenSingleFlight(account, "idle");
2043
- accountManager.requestSaveToDisk();
2044
2219
  return;
2045
2220
  } catch (err) {
2046
2221
  let details = parseRefreshFailure(err);
@@ -2068,7 +2243,6 @@ export async function AnthropicAuthPlugin({ client }) {
2068
2243
 
2069
2244
  try {
2070
2245
  await refreshAccountTokenSingleFlight(account, "idle");
2071
- accountManager.requestSaveToDisk();
2072
2246
  return;
2073
2247
  } catch (retryErr) {
2074
2248
  details = parseRefreshFailure(retryErr);
@@ -2125,7 +2299,7 @@ export async function AnthropicAuthPlugin({ client }) {
2125
2299
  input.command ??= {};
2126
2300
  input.command["anthropic"] = {
2127
2301
  template: "/anthropic",
2128
- description: "Manage Anthropic multi-account auth (status, usage, switch, login, reauth, logout)",
2302
+ description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)",
2129
2303
  };
2130
2304
  },
2131
2305
  "command.execute.before": async (input) => {
@@ -2252,8 +2426,8 @@ export async function AnthropicAuthPlugin({ client }) {
2252
2426
  const attemptedRefreshToken = account.refreshToken;
2253
2427
  try {
2254
2428
  accessToken = await refreshAccountTokenSingleFlight(account);
2255
- // Persist updated tokens (especially if refresh token rotated)
2256
- accountManager.requestSaveToDisk();
2429
+ // Tokens are now saved under the refresh lock (inside
2430
+ // refreshAccountToken) so no debounced save needed here.
2257
2431
  } catch (err) {
2258
2432
  // Token refresh failed — check if another instance rotated the
2259
2433
  // refresh token and persisted it between attempts.
@@ -2287,7 +2461,6 @@ export async function AnthropicAuthPlugin({ client }) {
2287
2461
 
2288
2462
  try {
2289
2463
  accessToken = await refreshAccountTokenSingleFlight(account);
2290
- accountManager.requestSaveToDisk();
2291
2464
  } catch (retryErr) {
2292
2465
  finalError = retryErr;
2293
2466
  details = parseRefreshFailure(retryErr);
@@ -2354,6 +2527,7 @@ export async function AnthropicAuthPlugin({ client }) {
2354
2527
  const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
2355
2528
  enabled: signatureEmulationEnabled,
2356
2529
  claudeCliVersion,
2530
+ customBetas: config.custom_betas,
2357
2531
  });
2358
2532
  // Execute the request
2359
2533
  let response;
package/lib/config.mjs CHANGED
@@ -51,6 +51,7 @@ import { homedir } from "node:os";
51
51
  * @property {boolean} debug
52
52
  * @property {{ enabled: boolean, fetch_claude_code_version_on_startup: boolean, prompt_compaction: 'minimal' | 'off' }} signature_emulation
53
53
  * @property {OverrideModelLimitsConfig} override_model_limits
54
+ * @property {string[]} custom_betas
54
55
  * @property {HealthScoreConfig} health_score
55
56
  * @property {TokenBucketConfig} token_bucket
56
57
  * @property {ToastConfig} toasts
@@ -60,7 +61,7 @@ import { homedir } from "node:os";
60
61
 
61
62
  /** @type {AnthropicAuthConfig} */
62
63
  export const DEFAULT_CONFIG = {
63
- account_selection_strategy: "sticky",
64
+ account_selection_strategy: "round-robin",
64
65
  failure_ttl_seconds: 3600,
65
66
  debug: false,
66
67
  signature_emulation: {
@@ -69,10 +70,11 @@ export const DEFAULT_CONFIG = {
69
70
  prompt_compaction: "minimal",
70
71
  },
71
72
  override_model_limits: {
72
- enabled: true,
73
+ enabled: false,
73
74
  context: 1_000_000,
74
75
  output: 0,
75
76
  },
77
+ custom_betas: [],
76
78
  health_score: {
77
79
  initial: 70,
78
80
  success_reward: 1,
@@ -112,6 +114,7 @@ function createDefaultConfig() {
112
114
  ...DEFAULT_CONFIG,
113
115
  signature_emulation: { ...DEFAULT_CONFIG.signature_emulation },
114
116
  override_model_limits: { ...DEFAULT_CONFIG.override_model_limits },
117
+ custom_betas: [...DEFAULT_CONFIG.custom_betas],
115
118
  health_score: { ...DEFAULT_CONFIG.health_score },
116
119
  token_bucket: { ...DEFAULT_CONFIG.token_bucket },
117
120
  toasts: { ...DEFAULT_CONFIG.toasts },
@@ -197,6 +200,11 @@ function validateConfig(raw) {
197
200
  };
198
201
  }
199
202
 
203
+ // Custom betas
204
+ if (Array.isArray(raw.custom_betas)) {
205
+ config.custom_betas = raw.custom_betas.filter((b) => typeof b === "string" && b.trim()).map((b) => b.trim());
206
+ }
207
+
200
208
  // Health score sub-config
201
209
  if (raw.health_score && typeof raw.health_score === "object") {
202
210
  const hs = /** @type {Record<string, unknown>} */ (raw.health_score);
@@ -408,19 +416,56 @@ export function loadRawConfig() {
408
416
  * Uses atomic write (temp + rename) for safety.
409
417
  * @param {Record<string, unknown>} updates - Keys to merge into the config file
410
418
  */
419
+ /**
420
+ * Deep merge source into target (one level deep for objects, replace for arrays/primitives).
421
+ * @param {Record<string, unknown>} target
422
+ * @param {Record<string, unknown>} source
423
+ * @returns {Record<string, unknown>}
424
+ */
425
+ function deepMergeConfig(target, source) {
426
+ const result = { ...target };
427
+ for (const [key, value] of Object.entries(source)) {
428
+ if (
429
+ value &&
430
+ typeof value === "object" &&
431
+ !Array.isArray(value) &&
432
+ typeof result[key] === "object" &&
433
+ result[key] &&
434
+ !Array.isArray(result[key])
435
+ ) {
436
+ result[key] = {
437
+ .../** @type {Record<string, unknown>} */ (result[key]),
438
+ .../** @type {Record<string, unknown>} */ (value),
439
+ };
440
+ } else {
441
+ result[key] = value;
442
+ }
443
+ }
444
+ return result;
445
+ }
446
+
411
447
  export function saveConfig(updates) {
412
448
  const configPath = getConfigPath();
413
449
  const dir = dirname(configPath);
414
450
  mkdirSync(dir, { recursive: true });
415
451
 
416
- // Read existing raw config
452
+ // Re-read from disk to avoid clobbering concurrent writes
417
453
  const existing = loadRawConfig();
418
454
 
419
- // Merge updates
420
- const merged = { ...existing, ...updates };
455
+ // Deep merge (one level) so sub-config objects are merged, not replaced
456
+ const merged = deepMergeConfig(existing, updates);
421
457
 
422
458
  // Atomic write: temp file + rename
423
459
  const tmpPath = configPath + `.tmp.${process.pid}`;
424
460
  writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
425
461
  renameSync(tmpPath, configPath);
426
462
  }
463
+
464
+ /**
465
+ * Load config fresh from disk (bypassing any startup cache).
466
+ * Useful for slash commands that need current on-disk state.
467
+ * @returns {AnthropicAuthConfig}
468
+ */
469
+ export function loadConfigFresh() {
470
+ return loadConfig();
471
+ }
@@ -10,7 +10,7 @@ vi.mock("node:fs", () => ({
10
10
 
11
11
  describe("DEFAULT_CONFIG", () => {
12
12
  it("has expected default strategy", () => {
13
- expect(DEFAULT_CONFIG.account_selection_strategy).toBe("sticky");
13
+ expect(DEFAULT_CONFIG.account_selection_strategy).toBe("round-robin");
14
14
  });
15
15
 
16
16
  it("has expected health score defaults", () => {
@@ -114,7 +114,7 @@ describe("loadConfig", () => {
114
114
  existsSync.mockReturnValue(true);
115
115
  readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "invalid" }));
116
116
  const config = loadConfig();
117
- expect(config.account_selection_strategy).toBe("sticky");
117
+ expect(config.account_selection_strategy).toBe("round-robin");
118
118
  });
119
119
 
120
120
  it("accepts boolean debug", () => {
@@ -199,7 +199,7 @@ describe("loadConfig", () => {
199
199
  existsSync.mockReturnValue(false);
200
200
  process.env.OPENCODE_ANTHROPIC_STRATEGY = "invalid";
201
201
  const config = loadConfig();
202
- expect(config.account_selection_strategy).toBe("sticky");
202
+ expect(config.account_selection_strategy).toBe("round-robin");
203
203
  });
204
204
 
205
205
  it("enables debug from OPENCODE_ANTHROPIC_DEBUG=1", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "main": "./index.mjs",
5
5
  "files": [
6
6
  "index.mjs",