opencode-anthropic-fix 0.0.21 → 0.0.23

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,11 +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`)
46
+ - **Effort-based thinking for Opus 4.6** — maps `budgetTokens` to effort levels (`low`/`medium`/`high`) and includes `effort-2025-11-24`
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
+ - **Files API integration** — upload, list, download, and manage files via `/anthropic files` with endpoint/content-scoped `files-api-2025-04-14` beta injection
50
+ - **Code execution support** — available via explicit custom beta opt-in (`code-execution-2025-08-25`), not auto-enabled
51
51
 
52
52
  ## Installation
53
53
 
@@ -271,6 +271,8 @@ Add or remove beta flags that get included in every `anthropic-beta` header. Per
271
271
  /anthropic betas # show active betas (auto + custom) with presets
272
272
  /anthropic betas add <beta-name> # add a custom beta
273
273
  /anthropic betas remove <beta-name> # remove a custom beta
274
+ /anthropic betas add 1m # shortcut => context-1m-2025-08-07
275
+ /anthropic betas add fast # shortcut => fast-mode-2026-02-01
274
276
  ```
275
277
 
276
278
  **Available preset betas** (shown by `/anthropic betas`):
@@ -284,6 +286,8 @@ Add or remove beta flags that get included in every `anthropic-beta` header. Per
284
286
  | `compact-2026-01-12` | Conversation compaction endpoint |
285
287
  | `mcp-servers-2025-12-04` | MCP servers in API request payload |
286
288
  | `web-search-2025-03-05` | Web search (Vertex/Foundry only) |
289
+ | `context-1m-2025-08-07` | 1M context beta (provider/model dependent) |
290
+ | `fast-mode-2026-02-01` | Faster Opus mode (experimental) |
287
291
 
288
292
  Example workflow:
289
293
 
@@ -295,10 +299,10 @@ Example workflow:
295
299
 
296
300
  ### Files API management
297
301
 
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.
302
+ Upload, list, download, and delete files via the Anthropic Files API. The `files-api-2025-04-14` beta is injected only when needed (`/v1/files` requests or Messages payloads with `file_id`).
299
303
 
300
304
  ```text
301
- /anthropic files # list uploaded files
305
+ /anthropic files # list files across ALL accounts
302
306
  /anthropic files upload ./report.pdf # upload a file
303
307
  /anthropic files get file_abc123 # get file metadata
304
308
  /anthropic files download file_abc123 # download to current directory
@@ -308,6 +312,16 @@ Upload, list, download, and delete files via the Anthropic Files API. The `files
308
312
 
309
313
  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
314
 
315
+ **Multi-account behavior:** Files on Anthropic are per-account. With multiple accounts:
316
+
317
+ - `/anthropic files list` (no `--account`) queries **all** enabled accounts, labeling each file with its owner email
318
+ - Use `--account <email|index>` to target a specific account for any action:
319
+ ```text
320
+ /anthropic files list --account alice@example.com
321
+ /anthropic files upload ./data.csv --account 2
322
+ ```
323
+ - **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.
324
+
311
325
  ### OAuth flows from slash command
312
326
 
313
327
  Login and reauth are two-step flows in slash mode:
@@ -333,11 +347,11 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
333
347
 
334
348
  Control how the plugin picks which account to use for each request.
335
349
 
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 |
350
+ | Strategy | Behavior | Best For |
351
+ | ---------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
352
+ | **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, predictable behavior, full feature compatibility |
353
+ | **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts (see [limitations](#round-robin-limitations)) |
354
+ | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
341
355
 
342
356
  ### Change Strategy
343
357
 
@@ -352,14 +366,37 @@ export OPENCODE_ANTHROPIC_STRATEGY=hybrid
352
366
  # Edit ~/.config/opencode/anthropic-auth.json
353
367
  ```
354
368
 
369
+ ### Round-Robin Limitations
370
+
371
+ Some Anthropic API features maintain server-side per-account state that breaks when requests alternate between accounts:
372
+
373
+ | Feature | Impact | Plugin Mitigation |
374
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
375
+ | **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 |
376
+ | **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 |
377
+ | **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 | **Manual opt-in only**: add the beta explicitly, and prefer sticky/pinned sessions for multi-step flows |
378
+ | **Message Batches** (`message-batches-2024-09-24`) | `batch_id` is per-account; polling from wrong account = 404 | No automatic mitigation (not auto-included) |
379
+
380
+ **Recommendation for round-robin users:** If you need prompt caching or code execution, pin each OpenCode session to a single account:
381
+
382
+ ```bash
383
+ # Terminal 1 — uses account 1
384
+ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=1 opencode
385
+
386
+ # Terminal 2 — uses account 2
387
+ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=2 opencode
388
+ ```
389
+
390
+ This automatically overrides the strategy to `sticky` for that session, re-enabling strategy-sensitive auto betas (for example `prompt-caching-scope-2026-01-05`). Other sessions are unaffected.
391
+
355
392
  ## Configuration
356
393
 
357
394
  Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All settings are optional &mdash; defaults work well for most users.
358
395
 
359
396
  ```jsonc
360
397
  {
361
- // Account selection strategy: "round-robin" | "sticky" | "hybrid"
362
- "account_selection_strategy": "round-robin",
398
+ // Account selection strategy: "sticky" | "round-robin" | "hybrid"
399
+ "account_selection_strategy": "sticky",
363
400
 
364
401
  // Seconds before consecutive failure count resets (60-7200)
365
402
  "failure_ttl_seconds": 3600,
@@ -427,16 +464,18 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
427
464
 
428
465
  ### Environment Variables
429
466
 
430
- | Variable | Description |
431
- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
432
- | `OPENCODE_ANTHROPIC_STRATEGY` | Override the account selection strategy at runtime. |
433
- | `OPENCODE_ANTHROPIC_DEBUG` | Set to `1` to enable debug logging. |
434
- | `OPENCODE_ANTHROPIC_QUIET` | Set to `1` to suppress non-error toasts (account status, switching). |
435
- | `OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE` | Set to `0` to disable Claude signature emulation (legacy mode). |
436
- | `OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION` | Set to `0` to skip npm version lookup at startup. |
437
- | `OPENCODE_ANTHROPIC_PROMPT_COMPACTION` | Set to `off` to disable default minimal system prompt compaction. |
438
- | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
439
- | `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). |
467
+ | Variable | Description |
468
+ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
469
+ | `OPENCODE_ANTHROPIC_STRATEGY` | Override the account selection strategy at runtime. |
470
+ | `OPENCODE_ANTHROPIC_DEBUG` | Set to `1` to enable debug logging. |
471
+ | `OPENCODE_ANTHROPIC_QUIET` | Set to `1` to suppress non-error toasts (account status, switching). |
472
+ | `OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE` | Set to `0` to disable Claude signature emulation (legacy mode). |
473
+ | `OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION` | Set to `0` to skip npm version lookup at startup. |
474
+ | `OPENCODE_ANTHROPIC_PROMPT_COMPACTION` | Set to `off` to disable default minimal system prompt compaction. |
475
+ | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
476
+ | `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). |
477
+ | `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). |
478
+ | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Set to `1` to suppress experimental auto-betas (mirrors Claude Code gateway safety switch). |
440
479
 
441
480
  ### OAuth-only behavior
442
481
 
@@ -462,7 +501,7 @@ The plugin also:
462
501
  - Emulates Claude-style request headers and beta flags by default
463
502
  - Sanitizes "OpenCode" references to "Claude Code" in system prompts (required by Anthropic's API)
464
503
  - In `prompt_compaction="minimal"`, deduplicates repeated/contained system blocks and uses a compact dedicated prompt for internal title-generation requests
465
- - Adds `?beta=true` to `/v1/messages` requests
504
+ - Adds `?beta=true` to `/v1/messages` and `/v1/messages/count_tokens` requests
466
505
 
467
506
  When signature emulation is disabled (`signature_emulation.enabled=false`), the plugin falls back to legacy behavior including the Claude Code system prompt prefix.
468
507
 
package/index.mjs CHANGED
@@ -106,6 +106,8 @@ async function promptManageAccounts(accountManager) {
106
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
+ const EFFORT_BETA_FLAG = "effort-2025-11-24";
110
+ const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
109
111
  const BILLING_HASH_SALT = "59cf53e54c78";
110
112
  const BILLING_HASH_POSITIONS = [4, 7, 20];
111
113
  const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
@@ -122,6 +124,32 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
122
124
  "code-execution-2025-08-25",
123
125
  "files-api-2025-04-14",
124
126
  ]);
127
+ const EXPERIMENTAL_BETA_FLAGS = new Set([
128
+ "adaptive-thinking-2026-01-28",
129
+ "advanced-tool-use-2025-11-20",
130
+ "afk-mode-2026-01-31",
131
+ "code-execution-2025-08-25",
132
+ "context-1m-2025-08-07",
133
+ "context-management-2025-06-27",
134
+ "fast-mode-2026-02-01",
135
+ "files-api-2025-04-14",
136
+ "fine-grained-tool-streaming-2025-05-14",
137
+ "interleaved-thinking-2025-05-14",
138
+ "prompt-caching-scope-2026-01-05",
139
+ "redact-thinking-2026-02-12",
140
+ "structured-outputs-2025-12-15",
141
+ "tool-examples-2025-10-29",
142
+ "tool-search-tool-2025-10-19",
143
+ "web-search-2025-03-05",
144
+ ]);
145
+ const BETA_SHORTCUTS = new Map([
146
+ ["1m", "context-1m-2025-08-07"],
147
+ ["1m-context", "context-1m-2025-08-07"],
148
+ ["context-1m", "context-1m-2025-08-07"],
149
+ ["fast", "fast-mode-2026-02-01"],
150
+ ["fast-mode", "fast-mode-2026-02-01"],
151
+ ["opus-fast", "fast-mode-2026-02-01"],
152
+ ]);
125
153
  const STAINLESS_HELPER_KEYS = [
126
154
  "x_stainless_helper",
127
155
  "x-stainless-helper",
@@ -162,6 +190,17 @@ function isFalsyEnv(value) {
162
190
  return normalized === "0" || normalized === "false" || normalized === "no";
163
191
  }
164
192
 
193
+ /**
194
+ * @param {string | undefined} value
195
+ * @returns {string}
196
+ */
197
+ function resolveBetaShortcut(value) {
198
+ if (!value) return "";
199
+ const trimmed = value.trim();
200
+ const mapped = BETA_SHORTCUTS.get(trimmed.toLowerCase());
201
+ return mapped || trimmed;
202
+ }
203
+
165
204
  /**
166
205
  * @returns {boolean}
167
206
  */
@@ -341,11 +380,11 @@ function detectProvider(requestUrl) {
341
380
 
342
381
  /**
343
382
  * @param {string | undefined} body
344
- * @returns {{model: string, tools: any[], messages: any[]}}
383
+ * @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
345
384
  */
346
385
  function parseRequestBodyMetadata(body) {
347
386
  if (!body || typeof body !== "string") {
348
- return { model: "", tools: [], messages: [] };
387
+ return { model: "", tools: [], messages: [], hasFileReferences: false };
349
388
  }
350
389
 
351
390
  try {
@@ -353,9 +392,10 @@ function parseRequestBodyMetadata(body) {
353
392
  const model = typeof parsed?.model === "string" ? parsed.model : "";
354
393
  const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
355
394
  const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
356
- return { model, tools, messages };
395
+ const hasFileReferences = extractFileIds(parsed).length > 0;
396
+ return { model, tools, messages, hasFileReferences };
357
397
  } catch {
358
- return { model: "", tools: [], messages: [] };
398
+ return { model: "", tools: [], messages: [], hasFileReferences: false };
359
399
  }
360
400
  }
361
401
 
@@ -662,85 +702,118 @@ function buildSystemPromptBlocks(system, signature, messages) {
662
702
  * @param {string} model
663
703
  * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
664
704
  * @param {string[]} [customBetas]
705
+ * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
706
+ * @param {string} [requestPath]
707
+ * @param {boolean} [hasFileReferences]
665
708
  * @returns {string}
666
709
  */
667
- function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas) {
710
+ function buildAnthropicBetaHeader(
711
+ incomingBeta,
712
+ signatureEnabled,
713
+ model,
714
+ provider,
715
+ customBetas,
716
+ strategy,
717
+ requestPath,
718
+ hasFileReferences,
719
+ ) {
668
720
  const incomingBetasList = incomingBeta
669
721
  .split(",")
670
722
  .map((b) => b.trim())
671
723
  .filter(Boolean);
672
724
 
673
725
  const betas = ["oauth-2025-04-20"];
726
+ const disableExperimentalBetas = isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS);
727
+ const isMessagesCountTokensPath = requestPath === "/v1/messages/count_tokens";
728
+ const isFilesEndpoint = requestPath?.startsWith("/v1/files") ?? false;
674
729
 
675
730
  if (!signatureEnabled) {
676
731
  betas.push("interleaved-thinking-2025-05-14");
677
- return [...new Set([...betas, ...incomingBetasList])].join(",");
732
+ if (isMessagesCountTokensPath) {
733
+ betas.push(TOKEN_COUNTING_BETA_FLAG);
734
+ }
735
+ let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
736
+ if (disableExperimentalBetas) {
737
+ mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
738
+ }
739
+ return mergedBetas.join(",");
678
740
  }
679
741
 
680
742
  const nonInteractive = isNonInteractiveMode();
681
743
  const haiku = isHaikuModel(model);
744
+ const isRoundRobin = strategy === "round-robin";
682
745
 
683
746
  if (!haiku) {
684
747
  betas.push(CLAUDE_CODE_BETA_FLAG);
685
- betas.push("code-execution-2025-08-25");
686
748
  }
687
749
 
688
- // Files API is model-independent; enables /v1/files endpoint and file_id references.
689
- betas.push("files-api-2025-04-14");
750
+ // Files API beta is endpoint/content-scoped instead of globally applied.
751
+ if ((isFilesEndpoint || hasFileReferences) && !disableExperimentalBetas) {
752
+ betas.push("files-api-2025-04-14");
753
+ }
690
754
 
691
755
  if (isOpus46Model(model)) {
692
- // Opus 4.6 uses adaptive thinking — model decides how much to think based on task complexity.
693
- // The effort parameter guides it; budgetTokens/interleaved-thinking are for older models.
694
- if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING)) {
695
- betas.push("adaptive-thinking-2026-01-28");
696
- }
697
- } else if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && supportsThinking(model)) {
756
+ // Opus 4.6 uses effort-based thinking controls.
757
+ betas.push(EFFORT_BETA_FLAG);
758
+ } else if (
759
+ !disableExperimentalBetas &&
760
+ !isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) &&
761
+ supportsThinking(model)
762
+ ) {
698
763
  betas.push("interleaved-thinking-2025-05-14");
699
764
  }
700
765
 
701
766
  // context-1m-2025-08-07 is only supported for API key users; OAuth provider does not support it.
702
767
  // For OAuth (this plugin's only auth mode), compaction is gated by model.limit.input instead.
703
- if (hasOneMillionContext(model) && provider !== "anthropic") {
768
+ if (!disableExperimentalBetas && hasOneMillionContext(model) && provider !== "anthropic") {
704
769
  betas.push("context-1m-2025-08-07");
705
770
  }
706
771
 
707
772
  if (
773
+ !disableExperimentalBetas &&
708
774
  nonInteractive &&
709
775
  (isTruthyEnv(process.env.USE_API_CONTEXT_MANAGEMENT) || isTruthyEnv(process.env.TENGU_MARBLE_ANVIL))
710
776
  ) {
711
777
  betas.push("context-management-2025-06-27");
712
778
  }
713
779
 
714
- if (supportsStructuredOutputs(model) && isTruthyEnv(process.env.TENGU_TOOL_PEAR)) {
780
+ if (!disableExperimentalBetas && supportsStructuredOutputs(model) && isTruthyEnv(process.env.TENGU_TOOL_PEAR)) {
715
781
  betas.push("structured-outputs-2025-12-15");
716
782
  }
717
783
 
718
- if (nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
784
+ if (!disableExperimentalBetas && nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
719
785
  betas.push("tool-examples-2025-10-29");
720
786
  }
721
787
 
722
- if ((provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
788
+ if (!disableExperimentalBetas && (provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
723
789
  betas.push("web-search-2025-03-05");
724
790
  }
725
791
 
726
- if (nonInteractive) {
792
+ // Prompt caching is per-workspace (since Feb 2026); round-robin across accounts
793
+ // means zero cache hits and doubled token costs. Skip in round-robin.
794
+ if (!disableExperimentalBetas && nonInteractive && !isRoundRobin) {
727
795
  betas.push("prompt-caching-scope-2026-01-05");
728
796
  }
729
797
 
730
- if (process.env.ANTHROPIC_BETAS && !haiku) {
798
+ if (isMessagesCountTokensPath) {
799
+ betas.push(TOKEN_COUNTING_BETA_FLAG);
800
+ }
801
+
802
+ if (process.env.ANTHROPIC_BETAS) {
731
803
  const envBetas = process.env.ANTHROPIC_BETAS.split(",")
732
804
  .map((b) => b.trim())
733
805
  .filter(Boolean);
734
806
  betas.push(...envBetas);
735
807
  }
736
808
 
737
- betas.push("fine-grained-tool-streaming-2025-05-14");
738
-
739
809
  if (Array.isArray(customBetas)) {
740
810
  betas.push(...customBetas.filter(Boolean));
741
811
  }
742
812
 
743
- const mergedBetas = [...new Set([...betas, ...incomingBetasList])];
813
+ let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
814
+ if (disableExperimentalBetas) {
815
+ mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
816
+ }
744
817
  if (provider === "bedrock") {
745
818
  return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
746
819
  }
@@ -750,7 +823,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
750
823
  /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
751
824
 
752
825
  /**
753
- * Map budgetTokens to an adaptive thinking effort level.
826
+ * Map budgetTokens to an effort level.
754
827
  * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
755
828
  * @param {number} budgetTokens
756
829
  * @returns {ThinkingEffort}
@@ -772,11 +845,11 @@ function isValidEffort(value) {
772
845
 
773
846
  /**
774
847
  * Normalise the `thinking` block in the request body for the target model:
775
- * - Opus 4.6 (adaptive thinking): produces `{ type: "enabled", effort: <effort> }`
848
+ * - Opus 4.6 (effort-based thinking): produces `{ type: "enabled", effort: <effort> }`
776
849
  * - Older models: passes the existing thinking block through unchanged.
777
850
  *
778
851
  * Handles three incoming shapes:
779
- * 1. Already adaptive: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
852
+ * 1. Already effort-based: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
780
853
  * 2. Legacy manual: `{ type: "enabled", budget_tokens: N }` → mapped to effort for Opus 4.6
781
854
  * 3. Absent / disabled: no transform
782
855
  *
@@ -864,7 +937,7 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
864
937
  * @param {string} accessToken
865
938
  * @param {string | undefined} requestBody
866
939
  * @param {URL | null} requestUrl
867
- * @param {{enabled: boolean, claudeCliVersion: string}} signature
940
+ * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[]}} signature
868
941
  * @returns {Headers}
869
942
  */
870
943
  function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
@@ -896,9 +969,18 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
896
969
 
897
970
  // Preserve all incoming beta headers while ensuring OAuth requirements
898
971
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
899
- const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
972
+ const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
900
973
  const provider = detectProvider(requestUrl);
901
- const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider, signature.customBetas);
974
+ const mergedBetas = buildAnthropicBetaHeader(
975
+ incomingBeta,
976
+ signature.enabled,
977
+ model,
978
+ provider,
979
+ signature.customBetas,
980
+ signature.strategy,
981
+ requestUrl?.pathname,
982
+ hasFileReferences,
983
+ );
902
984
 
903
985
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
904
986
  const bearerToken = authTokenOverride || accessToken;
@@ -1024,7 +1106,7 @@ function transformRequestBody(body, signature, runtime) {
1024
1106
  }
1025
1107
 
1026
1108
  /**
1027
- * Transform the request URL: add ?beta=true to /v1/messages.
1109
+ * Transform the request URL: add ?beta=true to /v1/messages and /v1/messages/count_tokens.
1028
1110
  * Preserves behaviors F1-F3.
1029
1111
  *
1030
1112
  * @param {any} input
@@ -1043,7 +1125,11 @@ function transformRequestUrl(input) {
1043
1125
  requestUrl = null;
1044
1126
  }
1045
1127
 
1046
- if (requestUrl && requestUrl.pathname === "/v1/messages" && !requestUrl.searchParams.has("beta")) {
1128
+ if (
1129
+ requestUrl &&
1130
+ (requestUrl.pathname === "/v1/messages" || requestUrl.pathname === "/v1/messages/count_tokens") &&
1131
+ !requestUrl.searchParams.has("beta")
1132
+ ) {
1047
1133
  requestUrl.searchParams.set("beta", "true");
1048
1134
  requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
1049
1135
  }
@@ -1566,6 +1652,30 @@ function parseCommandArgs(raw) {
1566
1652
  return parts;
1567
1653
  }
1568
1654
 
1655
+ /**
1656
+ * Extract all file_id values from a parsed Messages API request body.
1657
+ * Walks messages and system arrays looking for file content blocks.
1658
+ * @param {any} body - Parsed JSON body
1659
+ * @returns {string[]}
1660
+ */
1661
+ function extractFileIds(body) {
1662
+ const ids = [];
1663
+ if (!body || typeof body !== "object") return ids;
1664
+ function walk(obj) {
1665
+ if (Array.isArray(obj)) {
1666
+ for (const item of obj) walk(item);
1667
+ } else if (obj && typeof obj === "object") {
1668
+ if (obj.source?.file_id) ids.push(obj.source.file_id);
1669
+ for (const val of Object.values(obj)) {
1670
+ if (val && typeof val === "object") walk(val);
1671
+ }
1672
+ }
1673
+ }
1674
+ walk(body.messages);
1675
+ walk(body.system);
1676
+ return ids;
1677
+ }
1678
+
1569
1679
  /**
1570
1680
  * @type {import('@opencode-ai/plugin').Plugin}
1571
1681
  */
@@ -1596,12 +1706,27 @@ export async function AnthropicAuthPlugin({ client }) {
1596
1706
  const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
1597
1707
  const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
1598
1708
 
1709
+ /**
1710
+ * Whether OPENCODE_ANTHROPIC_INITIAL_ACCOUNT env var pinned this session to a
1711
+ * specific account. When true, syncActiveIndexFromDisk is skipped and strategy
1712
+ * is forced to sticky.
1713
+ */
1714
+ let initialAccountPinned = false;
1715
+
1599
1716
  /**
1600
1717
  * Pending slash-command OAuth flows keyed by session ID.
1601
1718
  * @type {Map<string, { mode: "login" | "reauth", verifier: string, targetIndex?: number, createdAt: number }>}
1602
1719
  */
1603
1720
  const pendingSlashOAuth = new Map();
1604
1721
 
1722
+ /**
1723
+ * In-memory mapping of file_id → account index for file-ID account pinning.
1724
+ * Populated by /anthropic files commands, consumed by the fetch interceptor
1725
+ * to route Messages API requests referencing file_ids to the correct account.
1726
+ * @type {Map<string, number>}
1727
+ */
1728
+ const fileAccountMap = new Map();
1729
+
1605
1730
  /**
1606
1731
  * Send an informational message into the current session.
1607
1732
  * @param {string} sessionID
@@ -1978,26 +2103,30 @@ export async function AnthropicAuthPlugin({ client }) {
1978
2103
 
1979
2104
  if (!action || action === "list") {
1980
2105
  const fresh = loadConfigFresh();
2106
+ const strategy = fresh.account_selection_strategy || config.account_selection_strategy;
1981
2107
  const lines = [
1982
2108
  "▣ Anthropic Betas",
1983
2109
  "",
1984
2110
  "Preset betas (auto-computed per model/provider):",
1985
2111
  " 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",
2112
+ " interleaved-thinking-2025-05-14 (non-Opus 4.6) OR effort-2025-11-24 (Opus 4.6),",
2113
+ " files-api-2025-04-14 (only /v1/files and requests with file_id),",
2114
+ " token-counting-2024-11-01 (only /v1/messages/count_tokens),",
2115
+ ` prompt-caching-scope-2026-01-05 (non-interactive${strategy === "round-robin" ? ", skipped in round-robin" : ""})`,
1990
2116
  "",
2117
+ `Experimental betas: ${isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) ? "disabled (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)" : "enabled"}`,
2118
+ `Strategy: ${strategy}${initialAccountPinned ? " (pinned via OPENCODE_ANTHROPIC_INITIAL_ACCOUNT)" : ""}`,
1991
2119
  `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1992
2120
  "",
1993
2121
  "Toggleable presets:",
1994
2122
  " /anthropic betas add structured-outputs-2025-12-15",
1995
2123
  " /anthropic betas add context-management-2025-06-27",
1996
- " /anthropic betas add prompt-caching-scope-2026-01-05",
1997
2124
  " /anthropic betas add tool-examples-2025-10-29",
1998
2125
  " /anthropic betas add web-search-2025-03-05",
1999
2126
  " /anthropic betas add compact-2026-01-12",
2000
2127
  " /anthropic betas add mcp-servers-2025-12-04",
2128
+ " /anthropic betas add 1m (shortcut for context-1m-2025-08-07)",
2129
+ " /anthropic betas add fast (shortcut for fast-mode-2026-02-01)",
2001
2130
  "",
2002
2131
  "Remove: /anthropic betas remove <beta>",
2003
2132
  ];
@@ -2006,11 +2135,12 @@ export async function AnthropicAuthPlugin({ client }) {
2006
2135
  }
2007
2136
 
2008
2137
  if (action === "add") {
2009
- const beta = args[2]?.trim();
2010
- if (!beta) {
2138
+ const betaInput = args[2]?.trim();
2139
+ if (!betaInput) {
2011
2140
  await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas add <beta-name>");
2012
2141
  return;
2013
2142
  }
2143
+ const beta = resolveBetaShortcut(betaInput);
2014
2144
  const fresh = loadConfigFresh();
2015
2145
  const current = fresh.custom_betas || [];
2016
2146
  if (current.includes(beta)) {
@@ -2019,16 +2149,21 @@ export async function AnthropicAuthPlugin({ client }) {
2019
2149
  }
2020
2150
  saveConfig({ custom_betas: [...current, beta] });
2021
2151
  Object.assign(config, loadConfigFresh());
2022
- await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nAdded: ${beta}`);
2152
+ const fromShortcut = beta !== betaInput;
2153
+ await sendCommandMessage(
2154
+ input.sessionID,
2155
+ `▣ Anthropic Betas\n\nAdded: ${beta}${fromShortcut ? ` (from shortcut: ${betaInput})` : ""}`,
2156
+ );
2023
2157
  return;
2024
2158
  }
2025
2159
 
2026
2160
  if (action === "remove" || action === "rm") {
2027
- const beta = args[2]?.trim();
2028
- if (!beta) {
2161
+ const betaInput = args[2]?.trim();
2162
+ if (!betaInput) {
2029
2163
  await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas remove <beta-name>");
2030
2164
  return;
2031
2165
  }
2166
+ const beta = resolveBetaShortcut(betaInput);
2032
2167
  const fresh = loadConfigFresh();
2033
2168
  const current = fresh.custom_betas || [];
2034
2169
  if (!current.includes(beta)) {
@@ -2046,12 +2181,23 @@ export async function AnthropicAuthPlugin({ client }) {
2046
2181
  }
2047
2182
 
2048
2183
  // /anthropic files [list|upload|get|delete|download] — Files API management
2184
+ // Supports --account <email|index> to target a specific account.
2185
+ // Without --account, list aggregates from ALL accounts; other actions use the current account.
2049
2186
  if (primary === "files") {
2050
- const action = (args[1] || "").toLowerCase();
2187
+ // Parse --account flag from args
2188
+ let targetAccountId = null;
2189
+ const filteredArgs = [];
2190
+ for (let i = 0; i < args.length; i++) {
2191
+ if (args[i] === "--account" && i + 1 < args.length) {
2192
+ targetAccountId = args[i + 1];
2193
+ i++;
2194
+ } else {
2195
+ filteredArgs.push(args[i]);
2196
+ }
2197
+ }
2198
+ const action = (filteredArgs[1] || "").toLowerCase();
2051
2199
 
2052
- // Get current account and ensure valid token
2053
- const account = accountManager?.getCurrentAccount();
2054
- if (!account) {
2200
+ if (!accountManager || accountManager.getAccountCount() === 0) {
2055
2201
  await sendCommandMessage(
2056
2202
  input.sessionID,
2057
2203
  "▣ Anthropic Files (error)\n\nNo accounts configured. Use /anthropic login first.",
@@ -2059,54 +2205,152 @@ export async function AnthropicAuthPlugin({ client }) {
2059
2205
  return;
2060
2206
  }
2061
2207
 
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;
2208
+ /**
2209
+ * Resolve a single account by email or 1-based index.
2210
+ * If identifier is null, falls back to the current account.
2211
+ * @param {string | null} identifier
2212
+ * @returns {{ account: import('./lib/accounts.mjs').ManagedAccount, label: string } | null}
2213
+ */
2214
+ function resolveTargetAccount(identifier) {
2215
+ const accounts = accountManager.getEnabledAccounts();
2216
+ if (identifier) {
2217
+ // Try by email
2218
+ const byEmail = accounts.find((a) => a.email === identifier);
2219
+ if (byEmail) return { account: byEmail, label: byEmail.email || `Account ${byEmail.index + 1}` };
2220
+ // Try by 1-based index
2221
+ const idx = parseInt(identifier, 10);
2222
+ if (!isNaN(idx) && idx >= 1) {
2223
+ const byIdx = accounts.find((a) => a.index === idx - 1);
2224
+ if (byIdx) return { account: byIdx, label: byIdx.email || `Account ${byIdx.index + 1}` };
2225
+ }
2226
+ return null;
2227
+ }
2228
+ // Default to current
2229
+ const current = accountManager.getCurrentAccount();
2230
+ if (!current) return null;
2231
+ return { account: current, label: current.email || `Account ${current.index + 1}` };
2232
+ }
2233
+
2234
+ /**
2235
+ * Get authenticated headers for a specific account, refreshing token if needed.
2236
+ * @param {import('./lib/accounts.mjs').ManagedAccount} acct
2237
+ */
2238
+ async function getFilesAuth(acct) {
2239
+ let tok = acct.access;
2240
+ if (!tok || !acct.expires || acct.expires < Date.now()) {
2241
+ tok = await refreshAccountTokenSingleFlight(acct);
2072
2242
  }
2243
+ return {
2244
+ authorization: `Bearer ${tok}`,
2245
+ "anthropic-beta": "oauth-2025-04-20,files-api-2025-04-14",
2246
+ };
2073
2247
  }
2074
2248
 
2075
2249
  const apiBase = "https://api.anthropic.com";
2076
- const authHeaders = {
2077
- authorization: `Bearer ${token}`,
2078
- "anthropic-beta": "files-api-2025-04-14",
2079
- };
2080
2250
 
2081
2251
  try {
2082
- // /anthropic files list — list all uploaded files
2252
+ // /anthropic files list — list uploaded files
2083
2253
  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}`);
2254
+ if (targetAccountId) {
2255
+ // List for a specific account
2256
+ const resolved = resolveTargetAccount(targetAccountId);
2257
+ if (!resolved) {
2258
+ await sendCommandMessage(
2259
+ input.sessionID,
2260
+ `▣ Anthropic Files (error)\n\nAccount not found: ${targetAccountId}`,
2261
+ );
2262
+ return;
2263
+ }
2264
+ const { account, label } = resolved;
2265
+ const headers = await getFilesAuth(account);
2266
+ const res = await fetch(`${apiBase}/v1/files`, { headers });
2267
+ if (!res.ok) {
2268
+ const errBody = await res.text();
2269
+ await sendCommandMessage(
2270
+ input.sessionID,
2271
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2272
+ );
2273
+ return;
2274
+ }
2275
+ const data = await res.json();
2276
+ const files = data.data || [];
2277
+ for (const f of files) fileAccountMap.set(f.id, account.index);
2278
+ if (files.length === 0) {
2279
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nNo files uploaded.`);
2280
+ return;
2281
+ }
2282
+ const lines = [`▣ Anthropic Files [${label}]`, "", `${files.length} file(s):`, ""];
2283
+ for (const f of files) {
2284
+ const sizeKB = (f.size / 1024).toFixed(1);
2285
+ lines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2286
+ }
2287
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2088
2288
  return;
2089
2289
  }
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;
2290
+
2291
+ // List files from ALL enabled accounts
2292
+ const accounts = accountManager.getEnabledAccounts();
2293
+ const allLines = ["▣ Anthropic Files (all accounts)", ""];
2294
+ let totalFiles = 0;
2295
+ for (const acct of accounts) {
2296
+ const label = acct.email || `Account ${acct.index + 1}`;
2297
+ try {
2298
+ const headers = await getFilesAuth(acct);
2299
+ const res = await fetch(`${apiBase}/v1/files`, { headers });
2300
+ if (!res.ok) {
2301
+ allLines.push(`[${label}] Error: HTTP ${res.status}`);
2302
+ allLines.push("");
2303
+ continue;
2304
+ }
2305
+ const data = await res.json();
2306
+ const files = data.data || [];
2307
+ for (const f of files) fileAccountMap.set(f.id, acct.index);
2308
+ totalFiles += files.length;
2309
+ if (files.length === 0) {
2310
+ allLines.push(`[${label}] No files`);
2311
+ } else {
2312
+ allLines.push(`[${label}] ${files.length} file(s):`);
2313
+ for (const f of files) {
2314
+ const sizeKB = (f.size / 1024).toFixed(1);
2315
+ allLines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2316
+ }
2317
+ }
2318
+ allLines.push("");
2319
+ } catch (err) {
2320
+ allLines.push(`[${label}] Error: ${err.message}`);
2321
+ allLines.push("");
2322
+ }
2095
2323
  }
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})`);
2324
+ if (totalFiles === 0 && accounts.length > 0) {
2325
+ allLines.push(`Total: No files across ${accounts.length} account(s).`);
2326
+ } else {
2327
+ allLines.push(`Total: ${totalFiles} file(s) across ${accounts.length} account(s).`);
2100
2328
  }
2101
- await sendCommandMessage(input.sessionID, lines.join("\n"));
2329
+ if (accounts.length > 1) {
2330
+ allLines.push("", "Tip: Use --account <email> to target a specific account.");
2331
+ }
2332
+ await sendCommandMessage(input.sessionID, allLines.join("\n"));
2102
2333
  return;
2103
2334
  }
2104
2335
 
2336
+ // For all non-list actions, resolve to a single account
2337
+ const resolved = resolveTargetAccount(targetAccountId);
2338
+ if (!resolved) {
2339
+ const errMsg = targetAccountId ? `Account not found: ${targetAccountId}` : "No accounts available.";
2340
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${errMsg}`);
2341
+ return;
2342
+ }
2343
+ const { account, label } = resolved;
2344
+ const authHeaders = await getFilesAuth(account);
2345
+
2105
2346
  // /anthropic files upload <path> — upload a file
2106
2347
  if (action === "upload") {
2107
- const filePath = args.slice(2).join(" ").trim();
2348
+ const filePath = filteredArgs.slice(2).join(" ").trim();
2108
2349
  if (!filePath) {
2109
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files upload <path>");
2350
+ await sendCommandMessage(
2351
+ input.sessionID,
2352
+ "▣ Anthropic Files\n\nUsage: /anthropic files upload <path> [--account <email>]",
2353
+ );
2110
2354
  return;
2111
2355
  }
2112
2356
  const resolvedPath = resolve(filePath);
@@ -2124,8 +2368,8 @@ export async function AnthropicAuthPlugin({ client }) {
2124
2368
  const res = await fetch(`${apiBase}/v1/files`, {
2125
2369
  method: "POST",
2126
2370
  headers: {
2127
- authorization: `Bearer ${token}`,
2128
- "anthropic-beta": "files-api-2025-04-14",
2371
+ authorization: authHeaders.authorization,
2372
+ "anthropic-beta": "oauth-2025-04-20,files-api-2025-04-14",
2129
2373
  },
2130
2374
  body: form,
2131
2375
  });
@@ -2133,35 +2377,44 @@ export async function AnthropicAuthPlugin({ client }) {
2133
2377
  const errBody = await res.text();
2134
2378
  await sendCommandMessage(
2135
2379
  input.sessionID,
2136
- `▣ Anthropic Files (error)\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
2380
+ `▣ Anthropic Files (error) [${label}]\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
2137
2381
  );
2138
2382
  return;
2139
2383
  }
2140
2384
  const file = await res.json();
2141
2385
  const sizeKB = ((file.size || 0) / 1024).toFixed(1);
2386
+ // Cache file_id → account mapping for auto-pinning
2387
+ fileAccountMap.set(file.id, account.index);
2142
2388
  await sendCommandMessage(
2143
2389
  input.sessionID,
2144
- `▣ Anthropic Files\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
2390
+ `▣ Anthropic Files [${label}]\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
2145
2391
  );
2146
2392
  return;
2147
2393
  }
2148
2394
 
2149
2395
  // /anthropic files get <file_id> — get file metadata
2150
2396
  if (action === "get" || action === "info") {
2151
- const fileId = args[2]?.trim();
2397
+ const fileId = filteredArgs[2]?.trim();
2152
2398
  if (!fileId) {
2153
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id>");
2399
+ await sendCommandMessage(
2400
+ input.sessionID,
2401
+ "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id> [--account <email>]",
2402
+ );
2154
2403
  return;
2155
2404
  }
2156
2405
  const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, { headers: authHeaders });
2157
2406
  if (!res.ok) {
2158
2407
  const errBody = await res.text();
2159
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2408
+ await sendCommandMessage(
2409
+ input.sessionID,
2410
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2411
+ );
2160
2412
  return;
2161
2413
  }
2162
2414
  const file = await res.json();
2415
+ fileAccountMap.set(file.id, account.index);
2163
2416
  const lines = [
2164
- "▣ Anthropic Files",
2417
+ `▣ Anthropic Files [${label}]`,
2165
2418
  "",
2166
2419
  ` ID: ${file.id}`,
2167
2420
  ` Filename: ${file.filename}`,
@@ -2176,9 +2429,12 @@ export async function AnthropicAuthPlugin({ client }) {
2176
2429
 
2177
2430
  // /anthropic files delete <file_id> — delete a file
2178
2431
  if (action === "delete" || action === "rm") {
2179
- const fileId = args[2]?.trim();
2432
+ const fileId = filteredArgs[2]?.trim();
2180
2433
  if (!fileId) {
2181
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id>");
2434
+ await sendCommandMessage(
2435
+ input.sessionID,
2436
+ "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id> [--account <email>]",
2437
+ );
2182
2438
  return;
2183
2439
  }
2184
2440
  const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
@@ -2187,24 +2443,28 @@ export async function AnthropicAuthPlugin({ client }) {
2187
2443
  });
2188
2444
  if (!res.ok) {
2189
2445
  const errBody = await res.text();
2190
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2446
+ await sendCommandMessage(
2447
+ input.sessionID,
2448
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2449
+ );
2191
2450
  return;
2192
2451
  }
2193
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files\n\nDeleted: ${fileId}`);
2452
+ fileAccountMap.delete(fileId);
2453
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nDeleted: ${fileId}`);
2194
2454
  return;
2195
2455
  }
2196
2456
 
2197
2457
  // /anthropic files download <file_id> [output_path] — download file content
2198
2458
  if (action === "download" || action === "dl") {
2199
- const fileId = args[2]?.trim();
2459
+ const fileId = filteredArgs[2]?.trim();
2200
2460
  if (!fileId) {
2201
2461
  await sendCommandMessage(
2202
2462
  input.sessionID,
2203
- "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path]",
2463
+ "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path] [--account <email>]",
2204
2464
  );
2205
2465
  return;
2206
2466
  }
2207
- const outputPath = args.slice(3).join(" ").trim();
2467
+ const outputPath = filteredArgs.slice(3).join(" ").trim();
2208
2468
 
2209
2469
  // Get file metadata first for the filename
2210
2470
  const metaRes = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
@@ -2214,7 +2474,7 @@ export async function AnthropicAuthPlugin({ client }) {
2214
2474
  const errBody = await metaRes.text();
2215
2475
  await sendCommandMessage(
2216
2476
  input.sessionID,
2217
- `▣ Anthropic Files (error)\n\nHTTP ${metaRes.status}: ${errBody}`,
2477
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${metaRes.status}: ${errBody}`,
2218
2478
  );
2219
2479
  return;
2220
2480
  }
@@ -2229,7 +2489,7 @@ export async function AnthropicAuthPlugin({ client }) {
2229
2489
  const errBody = await res.text();
2230
2490
  await sendCommandMessage(
2231
2491
  input.sessionID,
2232
- `▣ Anthropic Files (error)\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
2492
+ `▣ Anthropic Files (error) [${label}]\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
2233
2493
  );
2234
2494
  return;
2235
2495
  }
@@ -2238,7 +2498,7 @@ export async function AnthropicAuthPlugin({ client }) {
2238
2498
  const sizeKB = (buffer.length / 1024).toFixed(1);
2239
2499
  await sendCommandMessage(
2240
2500
  input.sessionID,
2241
- `▣ Anthropic Files\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
2501
+ `▣ Anthropic Files [${label}]\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
2242
2502
  );
2243
2503
  return;
2244
2504
  }
@@ -2247,17 +2507,23 @@ export async function AnthropicAuthPlugin({ client }) {
2247
2507
  const helpLines = [
2248
2508
  "▣ Anthropic Files",
2249
2509
  "",
2250
- "Usage: /anthropic files <action>",
2510
+ "Usage: /anthropic files <action> [--account <email|index>]",
2251
2511
  "",
2252
2512
  "Actions:",
2253
- " list List uploaded files",
2513
+ " list List uploaded files (all accounts if no --account)",
2254
2514
  " upload <path> Upload a file (max 350MB)",
2255
2515
  " get <file_id> Get file metadata",
2256
2516
  " delete <file_id> Delete a file",
2257
2517
  " download <file_id> [path] Download file content",
2258
2518
  "",
2519
+ "Options:",
2520
+ " --account <email|index> Target a specific account (1-based index)",
2521
+ "",
2259
2522
  "Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images",
2260
2523
  "Files can be referenced by file_id in Messages API requests.",
2524
+ "",
2525
+ "When using round-robin, file_ids are automatically pinned to the",
2526
+ "account that owns them for Messages API requests.",
2261
2527
  ];
2262
2528
  await sendCommandMessage(input.sessionID, helpLines.join("\n"));
2263
2529
  return;
@@ -2584,6 +2850,39 @@ export async function AnthropicAuthPlugin({ client }) {
2584
2850
  await accountManager.saveToDisk();
2585
2851
  }
2586
2852
 
2853
+ // OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pin this session to a specific account.
2854
+ // Accepts 1-based index or email. Overrides strategy to sticky and disables
2855
+ // syncActiveIndexFromDisk so other sessions can't override this one.
2856
+ // Use case: terminal 1 with INITIAL_ACCOUNT=1, terminal 2 with =2.
2857
+ const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
2858
+ if (initialAccountEnv && accountManager.getAccountCount() > 1) {
2859
+ const accounts = accountManager.getEnabledAccounts();
2860
+ let target = null;
2861
+
2862
+ // Try as 1-based index
2863
+ const asIndex = parseInt(initialAccountEnv, 10);
2864
+ if (!isNaN(asIndex) && asIndex >= 1 && asIndex <= accounts.length) {
2865
+ target = accounts[asIndex - 1];
2866
+ }
2867
+
2868
+ // Try as email
2869
+ if (!target) {
2870
+ target = accounts.find((a) => a.email && a.email.toLowerCase() === initialAccountEnv.toLowerCase());
2871
+ }
2872
+
2873
+ if (target && accountManager.forceCurrentIndex(target.index)) {
2874
+ config.account_selection_strategy = "sticky";
2875
+ initialAccountPinned = true;
2876
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pinned to account", {
2877
+ index: target.index + 1,
2878
+ email: target.email,
2879
+ strategy: "sticky (overridden)",
2880
+ });
2881
+ } else {
2882
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: could not resolve account", initialAccountEnv);
2883
+ }
2884
+ }
2885
+
2587
2886
  return {
2588
2887
  apiKey: "",
2589
2888
  /**
@@ -2612,7 +2911,9 @@ export async function AnthropicAuthPlugin({ client }) {
2612
2911
  const transientRefreshSkips = new Set();
2613
2912
 
2614
2913
  // Sync with CLI changes at request start.
2615
- if (accountManager) {
2914
+ // Skip when OPENCODE_ANTHROPIC_INITIAL_ACCOUNT pinned this session —
2915
+ // other sessions' CLI changes must not override the pinned account.
2916
+ if (accountManager && !initialAccountPinned) {
2616
2917
  await accountManager.syncActiveIndexFromDisk();
2617
2918
  }
2618
2919
 
@@ -2620,9 +2921,42 @@ export async function AnthropicAuthPlugin({ client }) {
2620
2921
  // switch to the next account. If it's service-wide, return immediately.
2621
2922
  const maxAttempts = accountManager.getTotalAccountCount();
2622
2923
 
2924
+ // File-ID account pinning: if the request body references file_ids
2925
+ // that we've mapped to a specific account (via /anthropic files),
2926
+ // pin the first attempt to that account so files are accessible.
2927
+ // Without this, round-robin could route to an account that doesn't
2928
+ // have the referenced files, causing file_not_found errors.
2929
+ let pinnedAccount = null;
2930
+ if (typeof requestInit.body === "string" && fileAccountMap.size > 0) {
2931
+ try {
2932
+ const bodyObj = JSON.parse(requestInit.body);
2933
+ const fileIds = extractFileIds(bodyObj);
2934
+ for (const fid of fileIds) {
2935
+ const pinnedIndex = fileAccountMap.get(fid);
2936
+ if (pinnedIndex !== undefined) {
2937
+ const candidates = accountManager.getEnabledAccounts();
2938
+ pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
2939
+ if (pinnedAccount) {
2940
+ debugLog("file-id pinning: routing to account", {
2941
+ fileId: fid,
2942
+ accountIndex: pinnedIndex,
2943
+ email: pinnedAccount.email,
2944
+ });
2945
+ break;
2946
+ }
2947
+ }
2948
+ }
2949
+ } catch {
2950
+ // Non-JSON body or parse error — skip pinning
2951
+ }
2952
+ }
2953
+
2623
2954
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2624
- // Select account
2625
- const account = accountManager.getCurrentAccount(transientRefreshSkips);
2955
+ // Select account — use pinned account on first attempt if available
2956
+ const account =
2957
+ attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2958
+ ? pinnedAccount
2959
+ : accountManager.getCurrentAccount(transientRefreshSkips);
2626
2960
 
2627
2961
  // Toast account usage on first use and whenever the account changes
2628
2962
  if (showUsageToast && account && accountManager) {
@@ -2756,6 +3090,7 @@ export async function AnthropicAuthPlugin({ client }) {
2756
3090
  enabled: signatureEmulationEnabled,
2757
3091
  claudeCliVersion,
2758
3092
  customBetas: config.custom_betas,
3093
+ strategy: config.account_selection_strategy,
2759
3094
  });
2760
3095
  // Execute the request
2761
3096
  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/lib/oauth.mjs CHANGED
@@ -5,6 +5,12 @@ import { CLIENT_ID } from "./config.mjs";
5
5
  // OAuth helpers — shared between plugin (index.mjs) and CLI (cli.mjs)
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
+ const OAUTH_CONSOLE_HOST = "platform.claude.com";
9
+ const OAUTH_MAX_HOST = "claude.ai";
10
+ const OAUTH_REDIRECT_URI = `https://${OAUTH_CONSOLE_HOST}/oauth/code/callback`;
11
+ const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
12
+ const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
13
+
8
14
  function base64url(input) {
9
15
  return Buffer.from(input).toString("base64url");
10
16
  }
@@ -23,11 +29,11 @@ function generatePKCE() {
23
29
  export async function authorize(mode) {
24
30
  const pkce = generatePKCE();
25
31
 
26
- const url = new URL(`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`);
32
+ const url = new URL(`https://${mode === "console" ? OAUTH_CONSOLE_HOST : OAUTH_MAX_HOST}/oauth/authorize`);
27
33
  url.searchParams.set("code", "true");
28
34
  url.searchParams.set("client_id", CLIENT_ID);
29
35
  url.searchParams.set("response_type", "code");
30
- url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback");
36
+ url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
31
37
  url.searchParams.set("scope", "org:create_api_key user:profile user:inference");
32
38
  url.searchParams.set("code_challenge", pkce.challenge);
33
39
  url.searchParams.set("code_challenge_method", "S256");
@@ -46,7 +52,7 @@ export async function authorize(mode) {
46
52
  */
47
53
  export async function exchange(code, verifier) {
48
54
  const splits = code.split("#");
49
- const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
55
+ const result = await fetch(OAUTH_TOKEN_URL, {
50
56
  method: "POST",
51
57
  headers: {
52
58
  "Content-Type": "application/json",
@@ -56,7 +62,7 @@ export async function exchange(code, verifier) {
56
62
  state: splits[1],
57
63
  grant_type: "authorization_code",
58
64
  client_id: CLIENT_ID,
59
- redirect_uri: "https://console.anthropic.com/oauth/code/callback",
65
+ redirect_uri: OAUTH_REDIRECT_URI,
60
66
  code_verifier: verifier,
61
67
  }),
62
68
  });
@@ -86,7 +92,7 @@ export async function exchange(code, verifier) {
86
92
  */
87
93
  export async function revoke(refreshToken) {
88
94
  try {
89
- const resp = await fetch("https://console.anthropic.com/v1/oauth/revoke", {
95
+ const resp = await fetch(OAUTH_REVOKE_URL, {
90
96
  method: "POST",
91
97
  headers: { "Content-Type": "application/json" },
92
98
  body: JSON.stringify({
@@ -110,7 +116,7 @@ export async function revoke(refreshToken) {
110
116
  * @throws {Error} On HTTP errors or network failures
111
117
  */
112
118
  export async function refreshToken(refreshTokenValue, options = {}) {
113
- const resp = await fetch("https://console.anthropic.com/v1/oauth/token", {
119
+ const resp = await fetch(OAUTH_TOKEN_URL, {
114
120
  method: "POST",
115
121
  headers: { "Content-Type": "application/json" },
116
122
  body: JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "main": "./index.mjs",
5
5
  "files": [
6
6
  "index.mjs",