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 +68 -19
- package/index.mjs +497 -12
- package/lib/accounts.mjs +14 -0
- package/lib/config.mjs +1 -1
- package/lib/config.test.mjs +3 -3
- package/package.json +1 -1
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
|
|
322
|
-
|
|
|
323
|
-
| **`
|
|
324
|
-
| **`
|
|
325
|
-
| **`hybrid`**
|
|
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 — defaults work well for most users.
|
|
343
391
|
|
|
344
392
|
```jsonc
|
|
345
393
|
{
|
|
346
|
-
// Account selection strategy: "
|
|
347
|
-
"account_selection_strategy": "
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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: "
|
|
64
|
+
account_selection_strategy: "sticky",
|
|
65
65
|
failure_ttl_seconds: 3600,
|
|
66
66
|
debug: false,
|
|
67
67
|
signature_emulation: {
|
package/lib/config.test.mjs
CHANGED
|
@@ -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("
|
|
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("
|
|
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("
|
|
202
|
+
expect(config.account_selection_strategy).toBe("sticky");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
it("enables debug from OPENCODE_ANTHROPIC_DEBUG=1", () => {
|