opencode-anthropic-fix 0.0.19 → 0.0.21

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,11 @@ 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
49
+ - **Files API integration** — upload, list, download, and manage files via `/anthropic files` with auto-included `files-api-2025-04-14` beta
50
+ - **Code execution support** — auto-includes `code-execution-2025-08-25` beta for sandbox code execution (non-Haiku models)
48
51
 
49
52
  ## Installation
50
53
 
@@ -231,22 +234,80 @@ Most commands have short aliases: `ln`, `lo`, `ra`, `rf`, `ls`, `st`, `sw`, `en`
231
234
 
232
235
  ## Slash Commands in OpenCode
233
236
 
234
- The plugin also registers a built-in `/anthropic` slash command so you can manage accounts without leaving OpenCode.
237
+ The plugin registers a built-in `/anthropic` slash command for account management, feature toggles, and custom beta headers — all without leaving OpenCode.
235
238
 
236
- ### Common examples
239
+ ### Account management
237
240
 
238
241
  ```text
239
- /anthropic # list (default)
242
+ /anthropic # list accounts (default)
240
243
  /anthropic usage # full account list + quota windows
241
244
  /anthropic switch 2
242
245
  /anthropic refresh 1
243
246
  /anthropic logout 2 # revoke tokens and remove account 2
244
247
  /anthropic logout --all # revoke all tokens and clear all accounts
245
- /anthropic strategy hybrid
246
248
  /anthropic stats
247
- /anthropic config
248
249
  ```
249
250
 
251
+ ### Runtime configuration
252
+
253
+ Toggle features live without restarting OpenCode. Changes are persisted to `anthropic-auth.json`.
254
+
255
+ ```text
256
+ /anthropic config # show all current settings
257
+ /anthropic set emulation on # enable/disable Claude signature emulation
258
+ /anthropic set compaction minimal # set prompt compaction mode (minimal/off)
259
+ /anthropic set 1m-context on # enable/disable 1M context limit override
260
+ /anthropic set idle-refresh on # enable/disable idle account refresh
261
+ /anthropic set strategy round-robin # change account selection strategy
262
+ /anthropic set debug on # enable/disable debug logging
263
+ /anthropic set quiet on # suppress non-error toasts
264
+ ```
265
+
266
+ ### Custom beta headers
267
+
268
+ Add or remove beta flags that get included in every `anthropic-beta` header. Persisted across sessions.
269
+
270
+ ```text
271
+ /anthropic betas # show active betas (auto + custom) with presets
272
+ /anthropic betas add <beta-name> # add a custom beta
273
+ /anthropic betas remove <beta-name> # remove a custom beta
274
+ ```
275
+
276
+ **Available preset betas** (shown by `/anthropic betas`):
277
+
278
+ | Beta | Description |
279
+ | --------------------------------- | ------------------------------------------------- |
280
+ | `prompt-caching-scope-2026-01-05` | Cache control with `scope: "org"` — free perf win |
281
+ | `context-management-2025-06-27` | Server-side auto-summarization when context fills |
282
+ | `structured-outputs-2025-12-15` | Strict JSON schema output enforcement |
283
+ | `tool-examples-2025-10-29` | Input/output examples in tool definitions |
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
294
+ ```
295
+
296
+ ### Files API management
297
+
298
+ Upload, list, download, and delete files via the Anthropic Files API. The `files-api-2025-04-14` beta is auto-included in every request.
299
+
300
+ ```text
301
+ /anthropic files # list uploaded files
302
+ /anthropic files upload ./report.pdf # upload a file
303
+ /anthropic files get file_abc123 # get file metadata
304
+ /anthropic files download file_abc123 # download to current directory
305
+ /anthropic files download file_abc123 ./out.pdf # download to specific path
306
+ /anthropic files delete file_abc123 # delete a file
307
+ ```
308
+
309
+ Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images (max 350 MB per file). Uploaded files can be referenced by `file_id` in Messages API requests.
310
+
250
311
  ### OAuth flows from slash command
251
312
 
252
313
  Login and reauth are two-step flows in slash mode:
@@ -272,11 +333,11 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
272
333
 
273
334
  Control how the plugin picks which account to use for each request.
274
335
 
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 |
336
+ | Strategy | Behavior | Best For |
337
+ | --------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- |
338
+ | **`round-robin`** (default) | Rotate through accounts on every request | Spreading load evenly across accounts |
339
+ | **`sticky`** | Stay on one account until it fails or is rate-limited | Single account, or when you want predictable behavior |
340
+ | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
280
341
 
281
342
  ### Change Strategy
282
343
 
@@ -297,8 +358,8 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
297
358
 
298
359
  ```jsonc
299
360
  {
300
- // Account selection strategy: "sticky" | "round-robin" | "hybrid"
301
- "account_selection_strategy": "sticky",
361
+ // Account selection strategy: "round-robin" | "sticky" | "hybrid"
362
+ "account_selection_strategy": "round-robin",
302
363
 
303
364
  // Seconds before consecutive failure count resets (60-7200)
304
365
  "failure_ttl_seconds": 3600,
@@ -325,8 +386,8 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
325
386
  // Only applied for OAuth (Max Plan) sessions — API key users use the
326
387
  // context-1m-2025-08-07 beta header instead.
327
388
  "override_model_limits": {
328
- // Enable/disable the override
329
- "enabled": true,
389
+ // Enable/disable the override (default: off — enable if you need 1M context)
390
+ "enabled": false,
330
391
  // Context window to inject (tokens). Default: 1_000_000.
331
392
  "context": 1000000,
332
393
  // Max output tokens to inject. 0 = leave the model's default unchanged.
@@ -351,6 +412,9 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
351
412
  "initial_tokens": 50,
352
413
  },
353
414
 
415
+ // Custom beta headers (added to every request via /anthropic betas add)
416
+ "custom_betas": [],
417
+
354
418
  // Toast notification settings
355
419
  "toasts": {
356
420
  // Suppress non-error toasts (account status, switching)
package/index.mjs CHANGED
@@ -2,10 +2,10 @@ import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
5
+ import { join, resolve, basename } 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";
@@ -119,6 +119,8 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
119
119
  "context-1m-2025-08-07",
120
120
  "tool-search-tool-2025-10-19",
121
121
  "tool-examples-2025-10-29",
122
+ "code-execution-2025-08-25",
123
+ "files-api-2025-04-14",
122
124
  ]);
123
125
  const STAINLESS_HELPER_KEYS = [
124
126
  "x_stainless_helper",
@@ -659,9 +661,10 @@ function buildSystemPromptBlocks(system, signature, messages) {
659
661
  * @param {boolean} signatureEnabled
660
662
  * @param {string} model
661
663
  * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
664
+ * @param {string[]} [customBetas]
662
665
  * @returns {string}
663
666
  */
664
- function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider) {
667
+ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas) {
665
668
  const incomingBetasList = incomingBeta
666
669
  .split(",")
667
670
  .map((b) => b.trim())
@@ -679,8 +682,12 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
679
682
 
680
683
  if (!haiku) {
681
684
  betas.push(CLAUDE_CODE_BETA_FLAG);
685
+ betas.push("code-execution-2025-08-25");
682
686
  }
683
687
 
688
+ // Files API is model-independent; enables /v1/files endpoint and file_id references.
689
+ betas.push("files-api-2025-04-14");
690
+
684
691
  if (isOpus46Model(model)) {
685
692
  // Opus 4.6 uses adaptive thinking — model decides how much to think based on task complexity.
686
693
  // The effort parameter guides it; budgetTokens/interleaved-thinking are for older models.
@@ -729,6 +736,10 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
729
736
 
730
737
  betas.push("fine-grained-tool-streaming-2025-05-14");
731
738
 
739
+ if (Array.isArray(customBetas)) {
740
+ betas.push(...customBetas.filter(Boolean));
741
+ }
742
+
732
743
  const mergedBetas = [...new Set([...betas, ...incomingBetasList])];
733
744
  if (provider === "bedrock") {
734
745
  return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
@@ -887,7 +898,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
887
898
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
888
899
  const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
889
900
  const provider = detectProvider(requestUrl);
890
- const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider);
901
+ const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider, signature.customBetas);
891
902
 
892
903
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
893
904
  const bearerToken = authTokenOverride || accessToken;
@@ -1900,6 +1911,362 @@ export async function AnthropicAuthPlugin({ client }) {
1900
1911
  return;
1901
1912
  }
1902
1913
 
1914
+ // /anthropic config — show effective config
1915
+ if (primary === "config") {
1916
+ const fresh = loadConfigFresh();
1917
+ const lines = [
1918
+ "▣ Anthropic Config",
1919
+ "",
1920
+ `strategy: ${fresh.account_selection_strategy}`,
1921
+ `emulation: ${fresh.signature_emulation.enabled ? "on" : "off"}`,
1922
+ `compaction: ${fresh.signature_emulation.prompt_compaction}`,
1923
+ `1m-context: ${fresh.override_model_limits.enabled ? "on" : "off"}`,
1924
+ `idle-refresh: ${fresh.idle_refresh.enabled ? "on" : "off"}`,
1925
+ `debug: ${fresh.debug ? "on" : "off"}`,
1926
+ `quiet: ${fresh.toasts.quiet ? "on" : "off"}`,
1927
+ `custom_betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1928
+ ];
1929
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
1930
+ return;
1931
+ }
1932
+
1933
+ // /anthropic set <key> <value> — toggle features at runtime
1934
+ if (primary === "set") {
1935
+ const key = (args[1] || "").toLowerCase();
1936
+ const value = (args[2] || "").toLowerCase();
1937
+ /** @type {Record<string, () => void>} */
1938
+ const setters = {
1939
+ emulation: () =>
1940
+ saveConfig({ signature_emulation: { enabled: value === "on" || value === "1" || value === "true" } }),
1941
+ compaction: () =>
1942
+ saveConfig({ signature_emulation: { prompt_compaction: value === "off" ? "off" : "minimal" } }),
1943
+ "1m-context": () =>
1944
+ saveConfig({ override_model_limits: { enabled: value === "on" || value === "1" || value === "true" } }),
1945
+ "idle-refresh": () =>
1946
+ saveConfig({ idle_refresh: { enabled: value === "on" || value === "1" || value === "true" } }),
1947
+ debug: () => saveConfig({ debug: value === "on" || value === "1" || value === "true" }),
1948
+ quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
1949
+ strategy: () => {
1950
+ const valid = ["sticky", "round-robin", "hybrid"];
1951
+ if (valid.includes(value)) saveConfig({ account_selection_strategy: value });
1952
+ else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
1953
+ },
1954
+ };
1955
+
1956
+ if (!key || !setters[key]) {
1957
+ const keys = Object.keys(setters).join(", ");
1958
+ await sendCommandMessage(
1959
+ input.sessionID,
1960
+ `▣ Anthropic Set\n\nUsage: /anthropic set <key> <value>\nKeys: ${keys}\nValues: on/off (or specific values for strategy/compaction)`,
1961
+ );
1962
+ return;
1963
+ }
1964
+ if (!value) {
1965
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\nMissing value for "${key}".`);
1966
+ return;
1967
+ }
1968
+ setters[key]();
1969
+ // Reload config into runtime
1970
+ Object.assign(config, loadConfigFresh());
1971
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\n${key} = ${value}`);
1972
+ return;
1973
+ }
1974
+
1975
+ // /anthropic betas [add|remove <beta>] — show/manage custom betas
1976
+ if (primary === "betas") {
1977
+ const action = (args[1] || "").toLowerCase();
1978
+
1979
+ if (!action || action === "list") {
1980
+ const fresh = loadConfigFresh();
1981
+ const lines = [
1982
+ "▣ Anthropic Betas",
1983
+ "",
1984
+ "Preset betas (auto-computed per model/provider):",
1985
+ " oauth-2025-04-20, claude-code-20250219,",
1986
+ " interleaved-thinking-2025-05-14 OR adaptive-thinking-2026-01-28,",
1987
+ " fine-grained-tool-streaming-2025-05-14,",
1988
+ " code-execution-2025-08-25 (non-Haiku),",
1989
+ " files-api-2025-04-14",
1990
+ "",
1991
+ `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1992
+ "",
1993
+ "Toggleable presets:",
1994
+ " /anthropic betas add structured-outputs-2025-12-15",
1995
+ " /anthropic betas add context-management-2025-06-27",
1996
+ " /anthropic betas add prompt-caching-scope-2026-01-05",
1997
+ " /anthropic betas add tool-examples-2025-10-29",
1998
+ " /anthropic betas add web-search-2025-03-05",
1999
+ " /anthropic betas add compact-2026-01-12",
2000
+ " /anthropic betas add mcp-servers-2025-12-04",
2001
+ "",
2002
+ "Remove: /anthropic betas remove <beta>",
2003
+ ];
2004
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2005
+ return;
2006
+ }
2007
+
2008
+ if (action === "add") {
2009
+ const beta = args[2]?.trim();
2010
+ if (!beta) {
2011
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas add <beta-name>");
2012
+ return;
2013
+ }
2014
+ const fresh = loadConfigFresh();
2015
+ const current = fresh.custom_betas || [];
2016
+ if (current.includes(beta)) {
2017
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" already added.`);
2018
+ return;
2019
+ }
2020
+ saveConfig({ custom_betas: [...current, beta] });
2021
+ Object.assign(config, loadConfigFresh());
2022
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nAdded: ${beta}`);
2023
+ return;
2024
+ }
2025
+
2026
+ if (action === "remove" || action === "rm") {
2027
+ const beta = args[2]?.trim();
2028
+ if (!beta) {
2029
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas remove <beta-name>");
2030
+ return;
2031
+ }
2032
+ const fresh = loadConfigFresh();
2033
+ const current = fresh.custom_betas || [];
2034
+ if (!current.includes(beta)) {
2035
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" not in custom betas.`);
2036
+ return;
2037
+ }
2038
+ saveConfig({ custom_betas: current.filter((b) => b !== beta) });
2039
+ Object.assign(config, loadConfigFresh());
2040
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nRemoved: ${beta}`);
2041
+ return;
2042
+ }
2043
+
2044
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas [add|remove <beta>]");
2045
+ return;
2046
+ }
2047
+
2048
+ // /anthropic files [list|upload|get|delete|download] — Files API management
2049
+ if (primary === "files") {
2050
+ const action = (args[1] || "").toLowerCase();
2051
+
2052
+ // Get current account and ensure valid token
2053
+ const account = accountManager?.getCurrentAccount();
2054
+ if (!account) {
2055
+ await sendCommandMessage(
2056
+ input.sessionID,
2057
+ "▣ Anthropic Files (error)\n\nNo accounts configured. Use /anthropic login first.",
2058
+ );
2059
+ return;
2060
+ }
2061
+
2062
+ let token = account.access;
2063
+ if (!token || !account.expires || account.expires < Date.now()) {
2064
+ try {
2065
+ token = await refreshAccountTokenSingleFlight(account);
2066
+ } catch (err) {
2067
+ await sendCommandMessage(
2068
+ input.sessionID,
2069
+ `▣ Anthropic Files (error)\n\nToken refresh failed: ${err.message}`,
2070
+ );
2071
+ return;
2072
+ }
2073
+ }
2074
+
2075
+ const apiBase = "https://api.anthropic.com";
2076
+ const authHeaders = {
2077
+ authorization: `Bearer ${token}`,
2078
+ "anthropic-beta": "files-api-2025-04-14",
2079
+ };
2080
+
2081
+ try {
2082
+ // /anthropic files list — list all uploaded files
2083
+ if (!action || action === "list") {
2084
+ const res = await fetch(`${apiBase}/v1/files`, { headers: authHeaders });
2085
+ if (!res.ok) {
2086
+ const errBody = await res.text();
2087
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2088
+ return;
2089
+ }
2090
+ const data = await res.json();
2091
+ const files = data.data || [];
2092
+ if (files.length === 0) {
2093
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nNo files uploaded.");
2094
+ return;
2095
+ }
2096
+ const lines = ["▣ Anthropic Files", "", `${files.length} file(s):`, ""];
2097
+ for (const f of files) {
2098
+ const sizeKB = (f.size / 1024).toFixed(1);
2099
+ lines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2100
+ }
2101
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2102
+ return;
2103
+ }
2104
+
2105
+ // /anthropic files upload <path> — upload a file
2106
+ if (action === "upload") {
2107
+ const filePath = args.slice(2).join(" ").trim();
2108
+ if (!filePath) {
2109
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files upload <path>");
2110
+ return;
2111
+ }
2112
+ const resolvedPath = resolve(filePath);
2113
+ if (!existsSync(resolvedPath)) {
2114
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nFile not found: ${resolvedPath}`);
2115
+ return;
2116
+ }
2117
+ const content = readFileSync(resolvedPath);
2118
+ const filename = basename(resolvedPath);
2119
+ const blob = new Blob([content]);
2120
+ const form = new FormData();
2121
+ form.append("file", blob, filename);
2122
+ form.append("purpose", "assistants");
2123
+
2124
+ const res = await fetch(`${apiBase}/v1/files`, {
2125
+ method: "POST",
2126
+ headers: {
2127
+ authorization: `Bearer ${token}`,
2128
+ "anthropic-beta": "files-api-2025-04-14",
2129
+ },
2130
+ body: form,
2131
+ });
2132
+ if (!res.ok) {
2133
+ const errBody = await res.text();
2134
+ await sendCommandMessage(
2135
+ input.sessionID,
2136
+ `▣ Anthropic Files (error)\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
2137
+ );
2138
+ return;
2139
+ }
2140
+ const file = await res.json();
2141
+ const sizeKB = ((file.size || 0) / 1024).toFixed(1);
2142
+ await sendCommandMessage(
2143
+ input.sessionID,
2144
+ `▣ Anthropic Files\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
2145
+ );
2146
+ return;
2147
+ }
2148
+
2149
+ // /anthropic files get <file_id> — get file metadata
2150
+ if (action === "get" || action === "info") {
2151
+ const fileId = args[2]?.trim();
2152
+ if (!fileId) {
2153
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id>");
2154
+ return;
2155
+ }
2156
+ const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, { headers: authHeaders });
2157
+ if (!res.ok) {
2158
+ const errBody = await res.text();
2159
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2160
+ return;
2161
+ }
2162
+ const file = await res.json();
2163
+ const lines = [
2164
+ "▣ Anthropic Files",
2165
+ "",
2166
+ ` ID: ${file.id}`,
2167
+ ` Filename: ${file.filename}`,
2168
+ ` Purpose: ${file.purpose}`,
2169
+ ` Size: ${((file.size || 0) / 1024).toFixed(1)} KB`,
2170
+ ` Type: ${file.mime_type || "unknown"}`,
2171
+ ` Created: ${file.created_at || "unknown"}`,
2172
+ ];
2173
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2174
+ return;
2175
+ }
2176
+
2177
+ // /anthropic files delete <file_id> — delete a file
2178
+ if (action === "delete" || action === "rm") {
2179
+ const fileId = args[2]?.trim();
2180
+ if (!fileId) {
2181
+ await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id>");
2182
+ return;
2183
+ }
2184
+ const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
2185
+ method: "DELETE",
2186
+ headers: authHeaders,
2187
+ });
2188
+ if (!res.ok) {
2189
+ const errBody = await res.text();
2190
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2191
+ return;
2192
+ }
2193
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files\n\nDeleted: ${fileId}`);
2194
+ return;
2195
+ }
2196
+
2197
+ // /anthropic files download <file_id> [output_path] — download file content
2198
+ if (action === "download" || action === "dl") {
2199
+ const fileId = args[2]?.trim();
2200
+ if (!fileId) {
2201
+ await sendCommandMessage(
2202
+ input.sessionID,
2203
+ "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path]",
2204
+ );
2205
+ return;
2206
+ }
2207
+ const outputPath = args.slice(3).join(" ").trim();
2208
+
2209
+ // Get file metadata first for the filename
2210
+ const metaRes = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
2211
+ headers: authHeaders,
2212
+ });
2213
+ if (!metaRes.ok) {
2214
+ const errBody = await metaRes.text();
2215
+ await sendCommandMessage(
2216
+ input.sessionID,
2217
+ `▣ Anthropic Files (error)\n\nHTTP ${metaRes.status}: ${errBody}`,
2218
+ );
2219
+ return;
2220
+ }
2221
+ const meta = await metaRes.json();
2222
+ const savePath = outputPath ? resolve(outputPath) : resolve(meta.filename);
2223
+
2224
+ // Download file content
2225
+ const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}/content`, {
2226
+ headers: authHeaders,
2227
+ });
2228
+ if (!res.ok) {
2229
+ const errBody = await res.text();
2230
+ await sendCommandMessage(
2231
+ input.sessionID,
2232
+ `▣ Anthropic Files (error)\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
2233
+ );
2234
+ return;
2235
+ }
2236
+ const buffer = Buffer.from(await res.arrayBuffer());
2237
+ writeFileSync(savePath, buffer);
2238
+ const sizeKB = (buffer.length / 1024).toFixed(1);
2239
+ await sendCommandMessage(
2240
+ input.sessionID,
2241
+ `▣ Anthropic Files\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
2242
+ );
2243
+ return;
2244
+ }
2245
+
2246
+ // Unknown action — show help
2247
+ const helpLines = [
2248
+ "▣ Anthropic Files",
2249
+ "",
2250
+ "Usage: /anthropic files <action>",
2251
+ "",
2252
+ "Actions:",
2253
+ " list List uploaded files",
2254
+ " upload <path> Upload a file (max 350MB)",
2255
+ " get <file_id> Get file metadata",
2256
+ " delete <file_id> Delete a file",
2257
+ " download <file_id> [path] Download file content",
2258
+ "",
2259
+ "Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images",
2260
+ "Files can be referenced by file_id in Messages API requests.",
2261
+ ];
2262
+ await sendCommandMessage(input.sessionID, helpLines.join("\n"));
2263
+ return;
2264
+ } catch (err) {
2265
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${err.message}`);
2266
+ return;
2267
+ }
2268
+ }
2269
+
1903
2270
  // Interactive CLI command is not compatible with slash flow.
1904
2271
  if (primary === "manage" || primary === "mg") {
1905
2272
  await sendCommandMessage(
@@ -2160,7 +2527,7 @@ export async function AnthropicAuthPlugin({ client }) {
2160
2527
  input.command ??= {};
2161
2528
  input.command["anthropic"] = {
2162
2529
  template: "/anthropic",
2163
- description: "Manage Anthropic multi-account auth (status, usage, switch, login, reauth, logout)",
2530
+ description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)",
2164
2531
  };
2165
2532
  },
2166
2533
  "command.execute.before": async (input) => {
@@ -2388,6 +2755,7 @@ export async function AnthropicAuthPlugin({ client }) {
2388
2755
  const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
2389
2756
  enabled: signatureEmulationEnabled,
2390
2757
  claudeCliVersion,
2758
+ customBetas: config.custom_betas,
2391
2759
  });
2392
2760
  // Execute the request
2393
2761
  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.19",
3
+ "version": "0.0.21",
4
4
  "main": "./index.mjs",
5
5
  "files": [
6
6
  "index.mjs",