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 +62 -23
- package/index.mjs +435 -100
- package/lib/accounts.mjs +14 -0
- package/lib/config.mjs +1 -1
- package/lib/config.test.mjs +3 -3
- package/lib/oauth.mjs +12 -6
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
|
50
|
-
- **Code execution support** —
|
|
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
|
|
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
|
|
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
|
|
337
|
-
|
|
|
338
|
-
| **`
|
|
339
|
-
| **`
|
|
340
|
-
| **`hybrid`**
|
|
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 — defaults work well for most users.
|
|
358
395
|
|
|
359
396
|
```jsonc
|
|
360
397
|
{
|
|
361
|
-
// Account selection strategy: "
|
|
362
|
-
"account_selection_strategy": "
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
689
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
|
1987
|
-
"
|
|
1988
|
-
"
|
|
1989
|
-
|
|
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
|
|
2010
|
-
if (!
|
|
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
|
-
|
|
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
|
|
2028
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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
|
|
2252
|
+
// /anthropic files list — list uploaded files
|
|
2083
2253
|
if (!action || action === "list") {
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
const
|
|
2087
|
-
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
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
|
-
|
|
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 =
|
|
2348
|
+
const filePath = filteredArgs.slice(2).join(" ").trim();
|
|
2108
2349
|
if (!filePath) {
|
|
2109
|
-
await sendCommandMessage(
|
|
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:
|
|
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 =
|
|
2397
|
+
const fileId = filteredArgs[2]?.trim();
|
|
2152
2398
|
if (!fileId) {
|
|
2153
|
-
await sendCommandMessage(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
2432
|
+
const fileId = filteredArgs[2]?.trim();
|
|
2180
2433
|
if (!fileId) {
|
|
2181
|
-
await sendCommandMessage(
|
|
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(
|
|
2446
|
+
await sendCommandMessage(
|
|
2447
|
+
input.sessionID,
|
|
2448
|
+
`▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
|
|
2449
|
+
);
|
|
2191
2450
|
return;
|
|
2192
2451
|
}
|
|
2193
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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: "
|
|
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", () => {
|
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" ?
|
|
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",
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
119
|
+
const resp = await fetch(OAUTH_TOKEN_URL, {
|
|
114
120
|
method: "POST",
|
|
115
121
|
headers: { "Content-Type": "application/json" },
|
|
116
122
|
body: JSON.stringify({
|