opencode-anthropic-fix 0.0.20 → 0.0.22

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
@@ -46,6 +46,8 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
46
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
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)
49
51
 
50
52
  ## Installation
51
53
 
@@ -279,8 +281,6 @@ Add or remove beta flags that get included in every `anthropic-beta` header. Per
279
281
  | `context-management-2025-06-27` | Server-side auto-summarization when context fills |
280
282
  | `structured-outputs-2025-12-15` | Strict JSON schema output enforcement |
281
283
  | `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
284
  | `compact-2026-01-12` | Conversation compaction endpoint |
285
285
  | `mcp-servers-2025-12-04` | MCP servers in API request payload |
286
286
  | `web-search-2025-03-05` | Web search (Vertex/Foundry only) |
@@ -293,6 +293,31 @@ Example workflow:
293
293
  /anthropic betas # verify
294
294
  ```
295
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 files across ALL accounts
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
+
311
+ **Multi-account behavior:** Files on Anthropic are per-account. With multiple accounts:
312
+
313
+ - `/anthropic files list` (no `--account`) queries **all** enabled accounts, labeling each file with its owner email
314
+ - Use `--account <email|index>` to target a specific account for any action:
315
+ ```text
316
+ /anthropic files list --account alice@example.com
317
+ /anthropic files upload ./data.csv --account 2
318
+ ```
319
+ - **Auto-pinning:** When you upload or list files, the plugin remembers which account owns each `file_id`. If a subsequent Messages API request references that `file_id`, the plugin automatically routes it to the correct account — even with round-robin or hybrid strategies.
320
+
296
321
  ### OAuth flows from slash command
297
322
 
298
323
  Login and reauth are two-step flows in slash mode:
@@ -318,11 +343,11 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
318
343
 
319
344
  Control how the plugin picks which account to use for each request.
320
345
 
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 |
346
+ | Strategy | Behavior | Best For |
347
+ | ---------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
348
+ | **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, predictable behavior, full feature compatibility |
349
+ | **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts (see [limitations](#round-robin-limitations)) |
350
+ | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
326
351
 
327
352
  ### Change Strategy
328
353
 
@@ -337,14 +362,37 @@ export OPENCODE_ANTHROPIC_STRATEGY=hybrid
337
362
  # Edit ~/.config/opencode/anthropic-auth.json
338
363
  ```
339
364
 
365
+ ### Round-Robin Limitations
366
+
367
+ Some Anthropic API features maintain server-side per-account state that breaks when requests alternate between accounts:
368
+
369
+ | Feature | Impact | Plugin Mitigation |
370
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
371
+ | **Files API** (`files-api-2025-04-14`) | `file_id` is per-account; referencing Account A's file from Account B = `file_not_found` | **Auto-pinning**: the plugin tracks which account owns each `file_id` and routes the request accordingly |
372
+ | **Prompt Caching** (`prompt-caching-scope-2026-01-05`) | Cache is per-workspace; alternating accounts means zero cache hits, doubling token costs | **Auto-skipped** in round-robin: the `prompt-caching-scope` beta is excluded from the header |
373
+ | **Code Execution** (`code-execution-2025-08-25`) | Sandbox state is ephemeral per-request; multi-step workflows lose files/state when routed to a different account | **Auto-skipped** in round-robin: the `code-execution` beta is excluded from the header |
374
+ | **Message Batches** (`message-batches-2024-09-24`) | `batch_id` is per-account; polling from wrong account = 404 | No automatic mitigation (not auto-included) |
375
+
376
+ **Recommendation for round-robin users:** If you need prompt caching or code execution, pin each OpenCode session to a single account:
377
+
378
+ ```bash
379
+ # Terminal 1 — uses account 1
380
+ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=1 opencode
381
+
382
+ # Terminal 2 — uses account 2
383
+ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=2 opencode
384
+ ```
385
+
386
+ This automatically overrides the strategy to `sticky` for that session, re-enabling all betas. Other sessions are unaffected.
387
+
340
388
  ## Configuration
341
389
 
342
390
  Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All settings are optional &mdash; defaults work well for most users.
343
391
 
344
392
  ```jsonc
345
393
  {
346
- // Account selection strategy: "round-robin" | "sticky" | "hybrid"
347
- "account_selection_strategy": "round-robin",
394
+ // Account selection strategy: "sticky" | "round-robin" | "hybrid"
395
+ "account_selection_strategy": "sticky",
348
396
 
349
397
  // Seconds before consecutive failure count resets (60-7200)
350
398
  "failure_ttl_seconds": 3600,
@@ -412,16 +460,17 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
412
460
 
413
461
  ### Environment Variables
414
462
 
415
- | Variable | Description |
416
- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
417
- | `OPENCODE_ANTHROPIC_STRATEGY` | Override the account selection strategy at runtime. |
418
- | `OPENCODE_ANTHROPIC_DEBUG` | Set to `1` to enable debug logging. |
419
- | `OPENCODE_ANTHROPIC_QUIET` | Set to `1` to suppress non-error toasts (account status, switching). |
420
- | `OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE` | Set to `0` to disable Claude signature emulation (legacy mode). |
421
- | `OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION` | Set to `0` to skip npm version lookup at startup. |
422
- | `OPENCODE_ANTHROPIC_PROMPT_COMPACTION` | Set to `off` to disable default minimal system prompt compaction. |
423
- | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
424
- | `OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS` | Set to `0` to disable context limit overrides for 1M-window models (e.g. when models.dev has been updated). |
463
+ | Variable | Description |
464
+ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
465
+ | `OPENCODE_ANTHROPIC_STRATEGY` | Override the account selection strategy at runtime. |
466
+ | `OPENCODE_ANTHROPIC_DEBUG` | Set to `1` to enable debug logging. |
467
+ | `OPENCODE_ANTHROPIC_QUIET` | Set to `1` to suppress non-error toasts (account status, switching). |
468
+ | `OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE` | Set to `0` to disable Claude signature emulation (legacy mode). |
469
+ | `OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION` | Set to `0` to skip npm version lookup at startup. |
470
+ | `OPENCODE_ANTHROPIC_PROMPT_COMPACTION` | Set to `off` to disable default minimal system prompt compaction. |
471
+ | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
472
+ | `OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS` | Set to `0` to disable context limit overrides for 1M-window models (e.g. when models.dev has been updated). |
473
+ | `OPENCODE_ANTHROPIC_INITIAL_ACCOUNT` | Pin this session to a specific account (1-based index or email). Overrides strategy to `sticky`. See [Round-Robin Limitations](#round-robin-limitations). |
425
474
 
426
475
  ### OAuth-only behavior
427
476
 
package/index.mjs CHANGED
@@ -2,7 +2,7 @@ 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
8
  import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.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",
@@ -660,9 +662,10 @@ function buildSystemPromptBlocks(system, signature, messages) {
660
662
  * @param {string} model
661
663
  * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
662
664
  * @param {string[]} [customBetas]
665
+ * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
663
666
  * @returns {string}
664
667
  */
665
- function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas) {
668
+ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas, strategy) {
666
669
  const incomingBetasList = incomingBeta
667
670
  .split(",")
668
671
  .map((b) => b.trim())
@@ -677,11 +680,21 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
677
680
 
678
681
  const nonInteractive = isNonInteractiveMode();
679
682
  const haiku = isHaikuModel(model);
683
+ const isRoundRobin = strategy === "round-robin";
680
684
 
681
685
  if (!haiku) {
682
686
  betas.push(CLAUDE_CODE_BETA_FLAG);
687
+ // Code execution sandbox state is per-account; skip in round-robin to avoid
688
+ // losing sandbox state mid-workflow when requests rotate between accounts.
689
+ if (!isRoundRobin) {
690
+ betas.push("code-execution-2025-08-25");
691
+ }
683
692
  }
684
693
 
694
+ // Files API is model-independent; enables /v1/files endpoint and file_id references.
695
+ // Safe in round-robin because file-ID account pinning routes requests to the correct account.
696
+ betas.push("files-api-2025-04-14");
697
+
685
698
  if (isOpus46Model(model)) {
686
699
  // Opus 4.6 uses adaptive thinking — model decides how much to think based on task complexity.
687
700
  // The effort parameter guides it; budgetTokens/interleaved-thinking are for older models.
@@ -717,7 +730,9 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
717
730
  betas.push("web-search-2025-03-05");
718
731
  }
719
732
 
720
- if (nonInteractive) {
733
+ // Prompt caching is per-workspace (since Feb 2026); round-robin across accounts
734
+ // means zero cache hits and doubled token costs. Skip in round-robin.
735
+ if (nonInteractive && !isRoundRobin) {
721
736
  betas.push("prompt-caching-scope-2026-01-05");
722
737
  }
723
738
 
@@ -858,7 +873,7 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
858
873
  * @param {string} accessToken
859
874
  * @param {string | undefined} requestBody
860
875
  * @param {URL | null} requestUrl
861
- * @param {{enabled: boolean, claudeCliVersion: string}} signature
876
+ * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy}} signature
862
877
  * @returns {Headers}
863
878
  */
864
879
  function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
@@ -892,7 +907,14 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
892
907
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
893
908
  const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
894
909
  const provider = detectProvider(requestUrl);
895
- const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider, signature.customBetas);
910
+ const mergedBetas = buildAnthropicBetaHeader(
911
+ incomingBeta,
912
+ signature.enabled,
913
+ model,
914
+ provider,
915
+ signature.customBetas,
916
+ signature.strategy,
917
+ );
896
918
 
897
919
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
898
920
  const bearerToken = authTokenOverride || accessToken;
@@ -1560,6 +1582,30 @@ function parseCommandArgs(raw) {
1560
1582
  return parts;
1561
1583
  }
1562
1584
 
1585
+ /**
1586
+ * Extract all file_id values from a parsed Messages API request body.
1587
+ * Walks messages and system arrays looking for file content blocks.
1588
+ * @param {any} body - Parsed JSON body
1589
+ * @returns {string[]}
1590
+ */
1591
+ function extractFileIds(body) {
1592
+ const ids = [];
1593
+ if (!body || typeof body !== "object") return ids;
1594
+ function walk(obj) {
1595
+ if (Array.isArray(obj)) {
1596
+ for (const item of obj) walk(item);
1597
+ } else if (obj && typeof obj === "object") {
1598
+ if (obj.source?.file_id) ids.push(obj.source.file_id);
1599
+ for (const val of Object.values(obj)) {
1600
+ if (val && typeof val === "object") walk(val);
1601
+ }
1602
+ }
1603
+ }
1604
+ walk(body.messages);
1605
+ walk(body.system);
1606
+ return ids;
1607
+ }
1608
+
1563
1609
  /**
1564
1610
  * @type {import('@opencode-ai/plugin').Plugin}
1565
1611
  */
@@ -1590,12 +1636,27 @@ export async function AnthropicAuthPlugin({ client }) {
1590
1636
  const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
1591
1637
  const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
1592
1638
 
1639
+ /**
1640
+ * Whether OPENCODE_ANTHROPIC_INITIAL_ACCOUNT env var pinned this session to a
1641
+ * specific account. When true, syncActiveIndexFromDisk is skipped and strategy
1642
+ * is forced to sticky.
1643
+ */
1644
+ let initialAccountPinned = false;
1645
+
1593
1646
  /**
1594
1647
  * Pending slash-command OAuth flows keyed by session ID.
1595
1648
  * @type {Map<string, { mode: "login" | "reauth", verifier: string, targetIndex?: number, createdAt: number }>}
1596
1649
  */
1597
1650
  const pendingSlashOAuth = new Map();
1598
1651
 
1652
+ /**
1653
+ * In-memory mapping of file_id → account index for file-ID account pinning.
1654
+ * Populated by /anthropic files commands, consumed by the fetch interceptor
1655
+ * to route Messages API requests referencing file_ids to the correct account.
1656
+ * @type {Map<string, number>}
1657
+ */
1658
+ const fileAccountMap = new Map();
1659
+
1599
1660
  /**
1600
1661
  * Send an informational message into the current session.
1601
1662
  * @param {string} sessionID
@@ -1972,26 +2033,28 @@ export async function AnthropicAuthPlugin({ client }) {
1972
2033
 
1973
2034
  if (!action || action === "list") {
1974
2035
  const fresh = loadConfigFresh();
2036
+ const strategy = fresh.account_selection_strategy || config.account_selection_strategy;
1975
2037
  const lines = [
1976
2038
  "▣ Anthropic Betas",
1977
2039
  "",
1978
2040
  "Preset betas (auto-computed per model/provider):",
1979
2041
  " oauth-2025-04-20, claude-code-20250219,",
1980
2042
  " interleaved-thinking-2025-05-14 OR adaptive-thinking-2026-01-28,",
1981
- " fine-grained-tool-streaming-2025-05-14",
2043
+ " fine-grained-tool-streaming-2025-05-14,",
2044
+ ` code-execution-2025-08-25 (non-Haiku${strategy === "round-robin" ? ", skipped in round-robin" : ""}),`,
2045
+ " files-api-2025-04-14,",
2046
+ ` prompt-caching-scope-2026-01-05 (non-interactive${strategy === "round-robin" ? ", skipped in round-robin" : ""})`,
1982
2047
  "",
2048
+ `Strategy: ${strategy}${initialAccountPinned ? " (pinned via OPENCODE_ANTHROPIC_INITIAL_ACCOUNT)" : ""}`,
1983
2049
  `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1984
2050
  "",
1985
2051
  "Toggleable presets:",
1986
2052
  " /anthropic betas add structured-outputs-2025-12-15",
1987
2053
  " /anthropic betas add context-management-2025-06-27",
1988
- " /anthropic betas add prompt-caching-scope-2026-01-05",
1989
2054
  " /anthropic betas add tool-examples-2025-10-29",
1990
2055
  " /anthropic betas add web-search-2025-03-05",
1991
- " /anthropic betas add code-execution-2025-08-25",
1992
2056
  " /anthropic betas add compact-2026-01-12",
1993
2057
  " /anthropic betas add mcp-servers-2025-12-04",
1994
- " /anthropic betas add files-api-2025-04-14",
1995
2058
  "",
1996
2059
  "Remove: /anthropic betas remove <beta>",
1997
2060
  ];
@@ -2039,6 +2102,359 @@ export async function AnthropicAuthPlugin({ client }) {
2039
2102
  return;
2040
2103
  }
2041
2104
 
2105
+ // /anthropic files [list|upload|get|delete|download] — Files API management
2106
+ // Supports --account <email|index> to target a specific account.
2107
+ // Without --account, list aggregates from ALL accounts; other actions use the current account.
2108
+ if (primary === "files") {
2109
+ // Parse --account flag from args
2110
+ let targetAccountId = null;
2111
+ const filteredArgs = [];
2112
+ for (let i = 0; i < args.length; i++) {
2113
+ if (args[i] === "--account" && i + 1 < args.length) {
2114
+ targetAccountId = args[i + 1];
2115
+ i++;
2116
+ } else {
2117
+ filteredArgs.push(args[i]);
2118
+ }
2119
+ }
2120
+ const action = (filteredArgs[1] || "").toLowerCase();
2121
+
2122
+ if (!accountManager || accountManager.getAccountCount() === 0) {
2123
+ await sendCommandMessage(
2124
+ input.sessionID,
2125
+ "▣ Anthropic Files (error)\n\nNo accounts configured. Use /anthropic login first.",
2126
+ );
2127
+ return;
2128
+ }
2129
+
2130
+ /**
2131
+ * Resolve a single account by email or 1-based index.
2132
+ * If identifier is null, falls back to the current account.
2133
+ * @param {string | null} identifier
2134
+ * @returns {{ account: import('./lib/accounts.mjs').ManagedAccount, label: string } | null}
2135
+ */
2136
+ function resolveTargetAccount(identifier) {
2137
+ const accounts = accountManager.getEnabledAccounts();
2138
+ if (identifier) {
2139
+ // Try by email
2140
+ const byEmail = accounts.find((a) => a.email === identifier);
2141
+ if (byEmail) return { account: byEmail, label: byEmail.email || `Account ${byEmail.index + 1}` };
2142
+ // Try by 1-based index
2143
+ const idx = parseInt(identifier, 10);
2144
+ if (!isNaN(idx) && idx >= 1) {
2145
+ const byIdx = accounts.find((a) => a.index === idx - 1);
2146
+ if (byIdx) return { account: byIdx, label: byIdx.email || `Account ${byIdx.index + 1}` };
2147
+ }
2148
+ return null;
2149
+ }
2150
+ // Default to current
2151
+ const current = accountManager.getCurrentAccount();
2152
+ if (!current) return null;
2153
+ return { account: current, label: current.email || `Account ${current.index + 1}` };
2154
+ }
2155
+
2156
+ /**
2157
+ * Get authenticated headers for a specific account, refreshing token if needed.
2158
+ * @param {import('./lib/accounts.mjs').ManagedAccount} acct
2159
+ */
2160
+ async function getFilesAuth(acct) {
2161
+ let tok = acct.access;
2162
+ if (!tok || !acct.expires || acct.expires < Date.now()) {
2163
+ tok = await refreshAccountTokenSingleFlight(acct);
2164
+ }
2165
+ return {
2166
+ authorization: `Bearer ${tok}`,
2167
+ "anthropic-beta": "files-api-2025-04-14",
2168
+ };
2169
+ }
2170
+
2171
+ const apiBase = "https://api.anthropic.com";
2172
+
2173
+ try {
2174
+ // /anthropic files list — list uploaded files
2175
+ if (!action || action === "list") {
2176
+ if (targetAccountId) {
2177
+ // List for a specific account
2178
+ const resolved = resolveTargetAccount(targetAccountId);
2179
+ if (!resolved) {
2180
+ await sendCommandMessage(
2181
+ input.sessionID,
2182
+ `▣ Anthropic Files (error)\n\nAccount not found: ${targetAccountId}`,
2183
+ );
2184
+ return;
2185
+ }
2186
+ const { account, label } = resolved;
2187
+ const headers = await getFilesAuth(account);
2188
+ const res = await fetch(`${apiBase}/v1/files`, { headers });
2189
+ if (!res.ok) {
2190
+ const errBody = await res.text();
2191
+ await sendCommandMessage(
2192
+ input.sessionID,
2193
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2194
+ );
2195
+ return;
2196
+ }
2197
+ const data = await res.json();
2198
+ const files = data.data || [];
2199
+ for (const f of files) fileAccountMap.set(f.id, account.index);
2200
+ if (files.length === 0) {
2201
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nNo files uploaded.`);
2202
+ return;
2203
+ }
2204
+ const lines = [`▣ Anthropic Files [${label}]`, "", `${files.length} file(s):`, ""];
2205
+ for (const f of files) {
2206
+ const sizeKB = (f.size / 1024).toFixed(1);
2207
+ lines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2208
+ }
2209
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2210
+ return;
2211
+ }
2212
+
2213
+ // List files from ALL enabled accounts
2214
+ const accounts = accountManager.getEnabledAccounts();
2215
+ const allLines = ["▣ Anthropic Files (all accounts)", ""];
2216
+ let totalFiles = 0;
2217
+ for (const acct of accounts) {
2218
+ const label = acct.email || `Account ${acct.index + 1}`;
2219
+ try {
2220
+ const headers = await getFilesAuth(acct);
2221
+ const res = await fetch(`${apiBase}/v1/files`, { headers });
2222
+ if (!res.ok) {
2223
+ allLines.push(`[${label}] Error: HTTP ${res.status}`);
2224
+ allLines.push("");
2225
+ continue;
2226
+ }
2227
+ const data = await res.json();
2228
+ const files = data.data || [];
2229
+ for (const f of files) fileAccountMap.set(f.id, acct.index);
2230
+ totalFiles += files.length;
2231
+ if (files.length === 0) {
2232
+ allLines.push(`[${label}] No files`);
2233
+ } else {
2234
+ allLines.push(`[${label}] ${files.length} file(s):`);
2235
+ for (const f of files) {
2236
+ const sizeKB = (f.size / 1024).toFixed(1);
2237
+ allLines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2238
+ }
2239
+ }
2240
+ allLines.push("");
2241
+ } catch (err) {
2242
+ allLines.push(`[${label}] Error: ${err.message}`);
2243
+ allLines.push("");
2244
+ }
2245
+ }
2246
+ if (totalFiles === 0 && accounts.length > 0) {
2247
+ allLines.push(`Total: No files across ${accounts.length} account(s).`);
2248
+ } else {
2249
+ allLines.push(`Total: ${totalFiles} file(s) across ${accounts.length} account(s).`);
2250
+ }
2251
+ if (accounts.length > 1) {
2252
+ allLines.push("", "Tip: Use --account <email> to target a specific account.");
2253
+ }
2254
+ await sendCommandMessage(input.sessionID, allLines.join("\n"));
2255
+ return;
2256
+ }
2257
+
2258
+ // For all non-list actions, resolve to a single account
2259
+ const resolved = resolveTargetAccount(targetAccountId);
2260
+ if (!resolved) {
2261
+ const errMsg = targetAccountId ? `Account not found: ${targetAccountId}` : "No accounts available.";
2262
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${errMsg}`);
2263
+ return;
2264
+ }
2265
+ const { account, label } = resolved;
2266
+ const authHeaders = await getFilesAuth(account);
2267
+
2268
+ // /anthropic files upload <path> — upload a file
2269
+ if (action === "upload") {
2270
+ const filePath = filteredArgs.slice(2).join(" ").trim();
2271
+ if (!filePath) {
2272
+ await sendCommandMessage(
2273
+ input.sessionID,
2274
+ "▣ Anthropic Files\n\nUsage: /anthropic files upload <path> [--account <email>]",
2275
+ );
2276
+ return;
2277
+ }
2278
+ const resolvedPath = resolve(filePath);
2279
+ if (!existsSync(resolvedPath)) {
2280
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nFile not found: ${resolvedPath}`);
2281
+ return;
2282
+ }
2283
+ const content = readFileSync(resolvedPath);
2284
+ const filename = basename(resolvedPath);
2285
+ const blob = new Blob([content]);
2286
+ const form = new FormData();
2287
+ form.append("file", blob, filename);
2288
+ form.append("purpose", "assistants");
2289
+
2290
+ const res = await fetch(`${apiBase}/v1/files`, {
2291
+ method: "POST",
2292
+ headers: {
2293
+ authorization: authHeaders.authorization,
2294
+ "anthropic-beta": "files-api-2025-04-14",
2295
+ },
2296
+ body: form,
2297
+ });
2298
+ if (!res.ok) {
2299
+ const errBody = await res.text();
2300
+ await sendCommandMessage(
2301
+ input.sessionID,
2302
+ `▣ Anthropic Files (error) [${label}]\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
2303
+ );
2304
+ return;
2305
+ }
2306
+ const file = await res.json();
2307
+ const sizeKB = ((file.size || 0) / 1024).toFixed(1);
2308
+ // Cache file_id → account mapping for auto-pinning
2309
+ fileAccountMap.set(file.id, account.index);
2310
+ await sendCommandMessage(
2311
+ input.sessionID,
2312
+ `▣ Anthropic Files [${label}]\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
2313
+ );
2314
+ return;
2315
+ }
2316
+
2317
+ // /anthropic files get <file_id> — get file metadata
2318
+ if (action === "get" || action === "info") {
2319
+ const fileId = filteredArgs[2]?.trim();
2320
+ if (!fileId) {
2321
+ await sendCommandMessage(
2322
+ input.sessionID,
2323
+ "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id> [--account <email>]",
2324
+ );
2325
+ return;
2326
+ }
2327
+ const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, { headers: authHeaders });
2328
+ if (!res.ok) {
2329
+ const errBody = await res.text();
2330
+ await sendCommandMessage(
2331
+ input.sessionID,
2332
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2333
+ );
2334
+ return;
2335
+ }
2336
+ const file = await res.json();
2337
+ fileAccountMap.set(file.id, account.index);
2338
+ const lines = [
2339
+ `▣ Anthropic Files [${label}]`,
2340
+ "",
2341
+ ` ID: ${file.id}`,
2342
+ ` Filename: ${file.filename}`,
2343
+ ` Purpose: ${file.purpose}`,
2344
+ ` Size: ${((file.size || 0) / 1024).toFixed(1)} KB`,
2345
+ ` Type: ${file.mime_type || "unknown"}`,
2346
+ ` Created: ${file.created_at || "unknown"}`,
2347
+ ];
2348
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2349
+ return;
2350
+ }
2351
+
2352
+ // /anthropic files delete <file_id> — delete a file
2353
+ if (action === "delete" || action === "rm") {
2354
+ const fileId = filteredArgs[2]?.trim();
2355
+ if (!fileId) {
2356
+ await sendCommandMessage(
2357
+ input.sessionID,
2358
+ "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id> [--account <email>]",
2359
+ );
2360
+ return;
2361
+ }
2362
+ const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
2363
+ method: "DELETE",
2364
+ headers: authHeaders,
2365
+ });
2366
+ if (!res.ok) {
2367
+ const errBody = await res.text();
2368
+ await sendCommandMessage(
2369
+ input.sessionID,
2370
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2371
+ );
2372
+ return;
2373
+ }
2374
+ fileAccountMap.delete(fileId);
2375
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nDeleted: ${fileId}`);
2376
+ return;
2377
+ }
2378
+
2379
+ // /anthropic files download <file_id> [output_path] — download file content
2380
+ if (action === "download" || action === "dl") {
2381
+ const fileId = filteredArgs[2]?.trim();
2382
+ if (!fileId) {
2383
+ await sendCommandMessage(
2384
+ input.sessionID,
2385
+ "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path] [--account <email>]",
2386
+ );
2387
+ return;
2388
+ }
2389
+ const outputPath = filteredArgs.slice(3).join(" ").trim();
2390
+
2391
+ // Get file metadata first for the filename
2392
+ const metaRes = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
2393
+ headers: authHeaders,
2394
+ });
2395
+ if (!metaRes.ok) {
2396
+ const errBody = await metaRes.text();
2397
+ await sendCommandMessage(
2398
+ input.sessionID,
2399
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${metaRes.status}: ${errBody}`,
2400
+ );
2401
+ return;
2402
+ }
2403
+ const meta = await metaRes.json();
2404
+ const savePath = outputPath ? resolve(outputPath) : resolve(meta.filename);
2405
+
2406
+ // Download file content
2407
+ const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}/content`, {
2408
+ headers: authHeaders,
2409
+ });
2410
+ if (!res.ok) {
2411
+ const errBody = await res.text();
2412
+ await sendCommandMessage(
2413
+ input.sessionID,
2414
+ `▣ Anthropic Files (error) [${label}]\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
2415
+ );
2416
+ return;
2417
+ }
2418
+ const buffer = Buffer.from(await res.arrayBuffer());
2419
+ writeFileSync(savePath, buffer);
2420
+ const sizeKB = (buffer.length / 1024).toFixed(1);
2421
+ await sendCommandMessage(
2422
+ input.sessionID,
2423
+ `▣ Anthropic Files [${label}]\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
2424
+ );
2425
+ return;
2426
+ }
2427
+
2428
+ // Unknown action — show help
2429
+ const helpLines = [
2430
+ "▣ Anthropic Files",
2431
+ "",
2432
+ "Usage: /anthropic files <action> [--account <email|index>]",
2433
+ "",
2434
+ "Actions:",
2435
+ " list List uploaded files (all accounts if no --account)",
2436
+ " upload <path> Upload a file (max 350MB)",
2437
+ " get <file_id> Get file metadata",
2438
+ " delete <file_id> Delete a file",
2439
+ " download <file_id> [path] Download file content",
2440
+ "",
2441
+ "Options:",
2442
+ " --account <email|index> Target a specific account (1-based index)",
2443
+ "",
2444
+ "Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images",
2445
+ "Files can be referenced by file_id in Messages API requests.",
2446
+ "",
2447
+ "When using round-robin, file_ids are automatically pinned to the",
2448
+ "account that owns them for Messages API requests.",
2449
+ ];
2450
+ await sendCommandMessage(input.sessionID, helpLines.join("\n"));
2451
+ return;
2452
+ } catch (err) {
2453
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${err.message}`);
2454
+ return;
2455
+ }
2456
+ }
2457
+
2042
2458
  // Interactive CLI command is not compatible with slash flow.
2043
2459
  if (primary === "manage" || primary === "mg") {
2044
2460
  await sendCommandMessage(
@@ -2356,6 +2772,39 @@ export async function AnthropicAuthPlugin({ client }) {
2356
2772
  await accountManager.saveToDisk();
2357
2773
  }
2358
2774
 
2775
+ // OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pin this session to a specific account.
2776
+ // Accepts 1-based index or email. Overrides strategy to sticky and disables
2777
+ // syncActiveIndexFromDisk so other sessions can't override this one.
2778
+ // Use case: terminal 1 with INITIAL_ACCOUNT=1, terminal 2 with =2.
2779
+ const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
2780
+ if (initialAccountEnv && accountManager.getAccountCount() > 1) {
2781
+ const accounts = accountManager.getEnabledAccounts();
2782
+ let target = null;
2783
+
2784
+ // Try as 1-based index
2785
+ const asIndex = parseInt(initialAccountEnv, 10);
2786
+ if (!isNaN(asIndex) && asIndex >= 1 && asIndex <= accounts.length) {
2787
+ target = accounts[asIndex - 1];
2788
+ }
2789
+
2790
+ // Try as email
2791
+ if (!target) {
2792
+ target = accounts.find((a) => a.email && a.email.toLowerCase() === initialAccountEnv.toLowerCase());
2793
+ }
2794
+
2795
+ if (target && accountManager.forceCurrentIndex(target.index)) {
2796
+ config.account_selection_strategy = "sticky";
2797
+ initialAccountPinned = true;
2798
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pinned to account", {
2799
+ index: target.index + 1,
2800
+ email: target.email,
2801
+ strategy: "sticky (overridden)",
2802
+ });
2803
+ } else {
2804
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: could not resolve account", initialAccountEnv);
2805
+ }
2806
+ }
2807
+
2359
2808
  return {
2360
2809
  apiKey: "",
2361
2810
  /**
@@ -2384,7 +2833,9 @@ export async function AnthropicAuthPlugin({ client }) {
2384
2833
  const transientRefreshSkips = new Set();
2385
2834
 
2386
2835
  // Sync with CLI changes at request start.
2387
- if (accountManager) {
2836
+ // Skip when OPENCODE_ANTHROPIC_INITIAL_ACCOUNT pinned this session —
2837
+ // other sessions' CLI changes must not override the pinned account.
2838
+ if (accountManager && !initialAccountPinned) {
2388
2839
  await accountManager.syncActiveIndexFromDisk();
2389
2840
  }
2390
2841
 
@@ -2392,9 +2843,42 @@ export async function AnthropicAuthPlugin({ client }) {
2392
2843
  // switch to the next account. If it's service-wide, return immediately.
2393
2844
  const maxAttempts = accountManager.getTotalAccountCount();
2394
2845
 
2846
+ // File-ID account pinning: if the request body references file_ids
2847
+ // that we've mapped to a specific account (via /anthropic files),
2848
+ // pin the first attempt to that account so files are accessible.
2849
+ // Without this, round-robin could route to an account that doesn't
2850
+ // have the referenced files, causing file_not_found errors.
2851
+ let pinnedAccount = null;
2852
+ if (typeof requestInit.body === "string" && fileAccountMap.size > 0) {
2853
+ try {
2854
+ const bodyObj = JSON.parse(requestInit.body);
2855
+ const fileIds = extractFileIds(bodyObj);
2856
+ for (const fid of fileIds) {
2857
+ const pinnedIndex = fileAccountMap.get(fid);
2858
+ if (pinnedIndex !== undefined) {
2859
+ const candidates = accountManager.getEnabledAccounts();
2860
+ pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
2861
+ if (pinnedAccount) {
2862
+ debugLog("file-id pinning: routing to account", {
2863
+ fileId: fid,
2864
+ accountIndex: pinnedIndex,
2865
+ email: pinnedAccount.email,
2866
+ });
2867
+ break;
2868
+ }
2869
+ }
2870
+ }
2871
+ } catch {
2872
+ // Non-JSON body or parse error — skip pinning
2873
+ }
2874
+ }
2875
+
2395
2876
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2396
- // Select account
2397
- const account = accountManager.getCurrentAccount(transientRefreshSkips);
2877
+ // Select account — use pinned account on first attempt if available
2878
+ const account =
2879
+ attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2880
+ ? pinnedAccount
2881
+ : accountManager.getCurrentAccount(transientRefreshSkips);
2398
2882
 
2399
2883
  // Toast account usage on first use and whenever the account changes
2400
2884
  if (showUsageToast && account && accountManager) {
@@ -2528,6 +3012,7 @@ export async function AnthropicAuthPlugin({ client }) {
2528
3012
  enabled: signatureEmulationEnabled,
2529
3013
  claudeCliVersion,
2530
3014
  customBetas: config.custom_betas,
3015
+ strategy: config.account_selection_strategy,
2531
3016
  });
2532
3017
  // Execute the request
2533
3018
  let response;
package/lib/accounts.mjs CHANGED
@@ -193,6 +193,20 @@ export class AccountManager {
193
193
  return this.#currentIndex;
194
194
  }
195
195
 
196
+ /**
197
+ * Force the active account to a specific index.
198
+ * Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
199
+ * @param {number} index
200
+ * @returns {boolean} Whether the index was valid and set
201
+ */
202
+ forceCurrentIndex(index) {
203
+ const account = this.#accounts[index];
204
+ if (!account || !account.enabled) return false;
205
+ this.#currentIndex = index;
206
+ this.#cursor = index;
207
+ return true;
208
+ }
209
+
196
210
  /**
197
211
  * Get enabled account references for internal plugin operations.
198
212
  * Returned objects are mutable managed accounts.
package/lib/config.mjs CHANGED
@@ -61,7 +61,7 @@ import { homedir } from "node:os";
61
61
 
62
62
  /** @type {AnthropicAuthConfig} */
63
63
  export const DEFAULT_CONFIG = {
64
- account_selection_strategy: "round-robin",
64
+ account_selection_strategy: "sticky",
65
65
  failure_ttl_seconds: 3600,
66
66
  debug: false,
67
67
  signature_emulation: {
@@ -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("round-robin");
13
+ expect(DEFAULT_CONFIG.account_selection_strategy).toBe("sticky");
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("round-robin");
117
+ expect(config.account_selection_strategy).toBe("sticky");
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("round-robin");
202
+ expect(config.account_selection_strategy).toBe("sticky");
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.20",
3
+ "version": "0.0.22",
4
4
  "main": "./index.mjs",
5
5
  "files": [
6
6
  "index.mjs",