opencode-anthropic-fix 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,11 +43,12 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
43
43
  - **Standalone CLI** — manage accounts without opening OpenCode
44
44
  - **Configurable strategies** — sticky, round-robin, or hybrid account selection
45
45
  - **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code
46
- - **Adaptive thinking for Opus 4.6** — automatically uses `adaptive-thinking-2026-01-28` and maps `budgetTokens` to effort levels (`low`/`medium`/`high`)
46
+ - **OAuth endpoint fingerprint parity** — sends Claude Code-style `User-Agent` on `/v1/oauth/token` and `/v1/oauth/revoke` to match current OAuth validation
47
+ - **Effort-based thinking for Opus 4.6** — maps `budgetTokens` to effort levels (`low`/`medium`/`high`) and includes `effort-2025-11-24`
47
48
  - **1M context limit override** — patches `model.limit.context` so OpenCode compacts at the right threshold while `models.dev` catches up
48
49
  - **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)
50
+ - **Files API integration** — upload, list, download, and manage files via `/anthropic files` with endpoint/content-scoped `files-api-2025-04-14` beta injection
51
+ - **Code execution support** — available via explicit custom beta opt-in (`code-execution-2025-08-25`), not auto-enabled
51
52
 
52
53
  ## Installation
53
54
 
@@ -271,6 +272,8 @@ Add or remove beta flags that get included in every `anthropic-beta` header. Per
271
272
  /anthropic betas # show active betas (auto + custom) with presets
272
273
  /anthropic betas add <beta-name> # add a custom beta
273
274
  /anthropic betas remove <beta-name> # remove a custom beta
275
+ /anthropic betas add 1m # shortcut => context-1m-2025-08-07
276
+ /anthropic betas add fast # shortcut => fast-mode-2026-02-01
274
277
  ```
275
278
 
276
279
  **Available preset betas** (shown by `/anthropic betas`):
@@ -284,6 +287,8 @@ Add or remove beta flags that get included in every `anthropic-beta` header. Per
284
287
  | `compact-2026-01-12` | Conversation compaction endpoint |
285
288
  | `mcp-servers-2025-12-04` | MCP servers in API request payload |
286
289
  | `web-search-2025-03-05` | Web search (Vertex/Foundry only) |
290
+ | `context-1m-2025-08-07` | 1M context beta (provider/model dependent) |
291
+ | `fast-mode-2026-02-01` | Faster Opus mode (experimental) |
287
292
 
288
293
  Example workflow:
289
294
 
@@ -295,7 +300,7 @@ Example workflow:
295
300
 
296
301
  ### Files API management
297
302
 
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.
303
+ 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
304
 
300
305
  ```text
301
306
  /anthropic files # list files across ALL accounts
@@ -370,7 +375,7 @@ Some Anthropic API features maintain server-side per-account state that breaks w
370
375
  | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
371
376
  | **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
377
  | **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 |
378
+ | **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 |
374
379
  | **Message Batches** (`message-batches-2024-09-24`) | `batch_id` is per-account; polling from wrong account = 404 | No automatic mitigation (not auto-included) |
375
380
 
376
381
  **Recommendation for round-robin users:** If you need prompt caching or code execution, pin each OpenCode session to a single account:
@@ -383,7 +388,7 @@ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=1 opencode
383
388
  OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=2 opencode
384
389
  ```
385
390
 
386
- This automatically overrides the strategy to `sticky` for that session, re-enabling all betas. Other sessions are unaffected.
391
+ 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.
387
392
 
388
393
  ## Configuration
389
394
 
@@ -471,6 +476,7 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
471
476
  | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
472
477
  | `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
478
  | `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). |
479
+ | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Set to `1` to suppress experimental auto-betas (mirrors Claude Code gateway safety switch). |
474
480
 
475
481
  ### OAuth-only behavior
476
482
 
@@ -496,7 +502,7 @@ The plugin also:
496
502
  - Emulates Claude-style request headers and beta flags by default
497
503
  - Sanitizes "OpenCode" references to "Claude Code" in system prompts (required by Anthropic's API)
498
504
  - In `prompt_compaction="minimal"`, deduplicates repeated/contained system blocks and uses a compact dedicated prompt for internal title-generation requests
499
- - Adds `?beta=true` to `/v1/messages` requests
505
+ - Adds `?beta=true` to `/v1/messages` and `/v1/messages/count_tokens` requests
500
506
 
501
507
  When signature emulation is disabled (`signature_emulation.enabled=false`), the plugin falls back to legacy behavior including the Claude Code system prompt prefix.
502
508
 
package/index.mjs CHANGED
@@ -103,9 +103,11 @@ async function promptManageAccounts(accountManager) {
103
103
  // Request building helpers (extracted from original fetch interceptor)
104
104
  // ---------------------------------------------------------------------------
105
105
 
106
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.76";
106
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.79";
107
107
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
108
108
  const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
109
+ const EFFORT_BETA_FLAG = "effort-2025-11-24";
110
+ const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
109
111
  const BILLING_HASH_SALT = "59cf53e54c78";
110
112
  const BILLING_HASH_POSITIONS = [4, 7, 20];
111
113
  const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
@@ -122,6 +124,32 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
122
124
  "code-execution-2025-08-25",
123
125
  "files-api-2025-04-14",
124
126
  ]);
127
+ const EXPERIMENTAL_BETA_FLAGS = new Set([
128
+ "adaptive-thinking-2026-01-28",
129
+ "advanced-tool-use-2025-11-20",
130
+ "afk-mode-2026-01-31",
131
+ "code-execution-2025-08-25",
132
+ "context-1m-2025-08-07",
133
+ "context-management-2025-06-27",
134
+ "fast-mode-2026-02-01",
135
+ "files-api-2025-04-14",
136
+ "fine-grained-tool-streaming-2025-05-14",
137
+ "interleaved-thinking-2025-05-14",
138
+ "prompt-caching-scope-2026-01-05",
139
+ "redact-thinking-2026-02-12",
140
+ "structured-outputs-2025-12-15",
141
+ "tool-examples-2025-10-29",
142
+ "tool-search-tool-2025-10-19",
143
+ "web-search-2025-03-05",
144
+ ]);
145
+ const BETA_SHORTCUTS = new Map([
146
+ ["1m", "context-1m-2025-08-07"],
147
+ ["1m-context", "context-1m-2025-08-07"],
148
+ ["context-1m", "context-1m-2025-08-07"],
149
+ ["fast", "fast-mode-2026-02-01"],
150
+ ["fast-mode", "fast-mode-2026-02-01"],
151
+ ["opus-fast", "fast-mode-2026-02-01"],
152
+ ]);
125
153
  const STAINLESS_HELPER_KEYS = [
126
154
  "x_stainless_helper",
127
155
  "x-stainless-helper",
@@ -162,6 +190,17 @@ function isFalsyEnv(value) {
162
190
  return normalized === "0" || normalized === "false" || normalized === "no";
163
191
  }
164
192
 
193
+ /**
194
+ * @param {string | undefined} value
195
+ * @returns {string}
196
+ */
197
+ function resolveBetaShortcut(value) {
198
+ if (!value) return "";
199
+ const trimmed = value.trim();
200
+ const mapped = BETA_SHORTCUTS.get(trimmed.toLowerCase());
201
+ return mapped || trimmed;
202
+ }
203
+
165
204
  /**
166
205
  * @returns {boolean}
167
206
  */
@@ -341,11 +380,11 @@ function detectProvider(requestUrl) {
341
380
 
342
381
  /**
343
382
  * @param {string | undefined} body
344
- * @returns {{model: string, tools: any[], messages: any[]}}
383
+ * @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
345
384
  */
346
385
  function parseRequestBodyMetadata(body) {
347
386
  if (!body || typeof body !== "string") {
348
- return { model: "", tools: [], messages: [] };
387
+ return { model: "", tools: [], messages: [], hasFileReferences: false };
349
388
  }
350
389
 
351
390
  try {
@@ -353,9 +392,10 @@ function parseRequestBodyMetadata(body) {
353
392
  const model = typeof parsed?.model === "string" ? parsed.model : "";
354
393
  const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
355
394
  const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
356
- return { model, tools, messages };
395
+ const hasFileReferences = extractFileIds(parsed).length > 0;
396
+ return { model, tools, messages, hasFileReferences };
357
397
  } catch {
358
- return { model: "", tools: [], messages: [] };
398
+ return { model: "", tools: [], messages: [], hasFileReferences: false };
359
399
  }
360
400
  }
361
401
 
@@ -663,19 +703,40 @@ function buildSystemPromptBlocks(system, signature, messages) {
663
703
  * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
664
704
  * @param {string[]} [customBetas]
665
705
  * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
706
+ * @param {string} [requestPath]
707
+ * @param {boolean} [hasFileReferences]
666
708
  * @returns {string}
667
709
  */
668
- function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas, strategy) {
710
+ function buildAnthropicBetaHeader(
711
+ incomingBeta,
712
+ signatureEnabled,
713
+ model,
714
+ provider,
715
+ customBetas,
716
+ strategy,
717
+ requestPath,
718
+ hasFileReferences,
719
+ ) {
669
720
  const incomingBetasList = incomingBeta
670
721
  .split(",")
671
722
  .map((b) => b.trim())
672
723
  .filter(Boolean);
673
724
 
674
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;
675
729
 
676
730
  if (!signatureEnabled) {
677
731
  betas.push("interleaved-thinking-2025-05-14");
678
- return [...new Set([...betas, ...incomingBetasList])].join(",");
732
+ if (isMessagesCountTokensPath) {
733
+ betas.push(TOKEN_COUNTING_BETA_FLAG);
734
+ }
735
+ let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
736
+ if (disableExperimentalBetas) {
737
+ mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
738
+ }
739
+ return mergedBetas.join(",");
679
740
  }
680
741
 
681
742
  const nonInteractive = isNonInteractiveMode();
@@ -684,72 +745,75 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
684
745
 
685
746
  if (!haiku) {
686
747
  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
- }
692
748
  }
693
749
 
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");
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
+ }
697
754
 
698
755
  if (isOpus46Model(model)) {
699
- // Opus 4.6 uses adaptive thinking — model decides how much to think based on task complexity.
700
- // The effort parameter guides it; budgetTokens/interleaved-thinking are for older models.
701
- if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING)) {
702
- betas.push("adaptive-thinking-2026-01-28");
703
- }
704
- } else if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && supportsThinking(model)) {
756
+ // Opus 4.6 uses effort-based thinking controls.
757
+ betas.push(EFFORT_BETA_FLAG);
758
+ } else if (
759
+ !disableExperimentalBetas &&
760
+ !isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) &&
761
+ supportsThinking(model)
762
+ ) {
705
763
  betas.push("interleaved-thinking-2025-05-14");
706
764
  }
707
765
 
708
766
  // context-1m-2025-08-07 is only supported for API key users; OAuth provider does not support it.
709
767
  // For OAuth (this plugin's only auth mode), compaction is gated by model.limit.input instead.
710
- if (hasOneMillionContext(model) && provider !== "anthropic") {
768
+ if (!disableExperimentalBetas && hasOneMillionContext(model) && provider !== "anthropic") {
711
769
  betas.push("context-1m-2025-08-07");
712
770
  }
713
771
 
714
772
  if (
773
+ !disableExperimentalBetas &&
715
774
  nonInteractive &&
716
775
  (isTruthyEnv(process.env.USE_API_CONTEXT_MANAGEMENT) || isTruthyEnv(process.env.TENGU_MARBLE_ANVIL))
717
776
  ) {
718
777
  betas.push("context-management-2025-06-27");
719
778
  }
720
779
 
721
- if (supportsStructuredOutputs(model) && isTruthyEnv(process.env.TENGU_TOOL_PEAR)) {
780
+ if (!disableExperimentalBetas && supportsStructuredOutputs(model) && isTruthyEnv(process.env.TENGU_TOOL_PEAR)) {
722
781
  betas.push("structured-outputs-2025-12-15");
723
782
  }
724
783
 
725
- if (nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
784
+ if (!disableExperimentalBetas && nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
726
785
  betas.push("tool-examples-2025-10-29");
727
786
  }
728
787
 
729
- if ((provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
788
+ if (!disableExperimentalBetas && (provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
730
789
  betas.push("web-search-2025-03-05");
731
790
  }
732
791
 
733
792
  // Prompt caching is per-workspace (since Feb 2026); round-robin across accounts
734
793
  // means zero cache hits and doubled token costs. Skip in round-robin.
735
- if (nonInteractive && !isRoundRobin) {
794
+ if (!disableExperimentalBetas && nonInteractive && !isRoundRobin) {
736
795
  betas.push("prompt-caching-scope-2026-01-05");
737
796
  }
738
797
 
739
- if (process.env.ANTHROPIC_BETAS && !haiku) {
798
+ if (isMessagesCountTokensPath) {
799
+ betas.push(TOKEN_COUNTING_BETA_FLAG);
800
+ }
801
+
802
+ if (process.env.ANTHROPIC_BETAS) {
740
803
  const envBetas = process.env.ANTHROPIC_BETAS.split(",")
741
804
  .map((b) => b.trim())
742
805
  .filter(Boolean);
743
806
  betas.push(...envBetas);
744
807
  }
745
808
 
746
- betas.push("fine-grained-tool-streaming-2025-05-14");
747
-
748
809
  if (Array.isArray(customBetas)) {
749
810
  betas.push(...customBetas.filter(Boolean));
750
811
  }
751
812
 
752
- const mergedBetas = [...new Set([...betas, ...incomingBetasList])];
813
+ let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
814
+ if (disableExperimentalBetas) {
815
+ mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
816
+ }
753
817
  if (provider === "bedrock") {
754
818
  return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
755
819
  }
@@ -759,7 +823,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
759
823
  /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
760
824
 
761
825
  /**
762
- * Map budgetTokens to an adaptive thinking effort level.
826
+ * Map budgetTokens to an effort level.
763
827
  * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
764
828
  * @param {number} budgetTokens
765
829
  * @returns {ThinkingEffort}
@@ -781,11 +845,11 @@ function isValidEffort(value) {
781
845
 
782
846
  /**
783
847
  * Normalise the `thinking` block in the request body for the target model:
784
- * - Opus 4.6 (adaptive thinking): produces `{ type: "enabled", effort: <effort> }`
848
+ * - Opus 4.6 (effort-based thinking): produces `{ type: "enabled", effort: <effort> }`
785
849
  * - Older models: passes the existing thinking block through unchanged.
786
850
  *
787
851
  * Handles three incoming shapes:
788
- * 1. Already adaptive: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
852
+ * 1. Already effort-based: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
789
853
  * 2. Legacy manual: `{ type: "enabled", budget_tokens: N }` → mapped to effort for Opus 4.6
790
854
  * 3. Absent / disabled: no transform
791
855
  *
@@ -873,7 +937,7 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
873
937
  * @param {string} accessToken
874
938
  * @param {string | undefined} requestBody
875
939
  * @param {URL | null} requestUrl
876
- * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy}} signature
940
+ * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[]}} signature
877
941
  * @returns {Headers}
878
942
  */
879
943
  function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
@@ -905,7 +969,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
905
969
 
906
970
  // Preserve all incoming beta headers while ensuring OAuth requirements
907
971
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
908
- const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
972
+ const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
909
973
  const provider = detectProvider(requestUrl);
910
974
  const mergedBetas = buildAnthropicBetaHeader(
911
975
  incomingBeta,
@@ -914,6 +978,8 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
914
978
  provider,
915
979
  signature.customBetas,
916
980
  signature.strategy,
981
+ requestUrl?.pathname,
982
+ hasFileReferences,
917
983
  );
918
984
 
919
985
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
@@ -1040,7 +1106,7 @@ function transformRequestBody(body, signature, runtime) {
1040
1106
  }
1041
1107
 
1042
1108
  /**
1043
- * 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.
1044
1110
  * Preserves behaviors F1-F3.
1045
1111
  *
1046
1112
  * @param {any} input
@@ -1059,7 +1125,11 @@ function transformRequestUrl(input) {
1059
1125
  requestUrl = null;
1060
1126
  }
1061
1127
 
1062
- if (requestUrl && requestUrl.pathname === "/v1/messages" && !requestUrl.searchParams.has("beta")) {
1128
+ if (
1129
+ requestUrl &&
1130
+ (requestUrl.pathname === "/v1/messages" || requestUrl.pathname === "/v1/messages/count_tokens") &&
1131
+ !requestUrl.searchParams.has("beta")
1132
+ ) {
1063
1133
  requestUrl.searchParams.set("beta", "true");
1064
1134
  requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
1065
1135
  }
@@ -1796,7 +1866,12 @@ export async function AnthropicAuthPlugin({ client }) {
1796
1866
 
1797
1867
  const credentials = await exchange(code, pending.verifier);
1798
1868
  if (credentials.type === "failed") {
1799
- return { ok: false, message: "Token exchange failed. The code may be invalid or expired." };
1869
+ return {
1870
+ ok: false,
1871
+ message: credentials.details
1872
+ ? `Token exchange failed (${credentials.details}).`
1873
+ : "Token exchange failed. The code may be invalid or expired.",
1874
+ };
1800
1875
  }
1801
1876
 
1802
1877
  const stored = (await loadAccounts()) || { version: 1, accounts: [], activeIndex: 0 };
@@ -2039,12 +2114,12 @@ export async function AnthropicAuthPlugin({ client }) {
2039
2114
  "",
2040
2115
  "Preset betas (auto-computed per model/provider):",
2041
2116
  " oauth-2025-04-20, claude-code-20250219,",
2042
- " interleaved-thinking-2025-05-14 OR adaptive-thinking-2026-01-28,",
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,",
2117
+ " interleaved-thinking-2025-05-14 (non-Opus 4.6) OR effort-2025-11-24 (Opus 4.6),",
2118
+ " files-api-2025-04-14 (only /v1/files and requests with file_id),",
2119
+ " token-counting-2024-11-01 (only /v1/messages/count_tokens),",
2046
2120
  ` prompt-caching-scope-2026-01-05 (non-interactive${strategy === "round-robin" ? ", skipped in round-robin" : ""})`,
2047
2121
  "",
2122
+ `Experimental betas: ${isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) ? "disabled (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)" : "enabled"}`,
2048
2123
  `Strategy: ${strategy}${initialAccountPinned ? " (pinned via OPENCODE_ANTHROPIC_INITIAL_ACCOUNT)" : ""}`,
2049
2124
  `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
2050
2125
  "",
@@ -2055,6 +2130,8 @@ export async function AnthropicAuthPlugin({ client }) {
2055
2130
  " /anthropic betas add web-search-2025-03-05",
2056
2131
  " /anthropic betas add compact-2026-01-12",
2057
2132
  " /anthropic betas add mcp-servers-2025-12-04",
2133
+ " /anthropic betas add 1m (shortcut for context-1m-2025-08-07)",
2134
+ " /anthropic betas add fast (shortcut for fast-mode-2026-02-01)",
2058
2135
  "",
2059
2136
  "Remove: /anthropic betas remove <beta>",
2060
2137
  ];
@@ -2063,11 +2140,12 @@ export async function AnthropicAuthPlugin({ client }) {
2063
2140
  }
2064
2141
 
2065
2142
  if (action === "add") {
2066
- const beta = args[2]?.trim();
2067
- if (!beta) {
2143
+ const betaInput = args[2]?.trim();
2144
+ if (!betaInput) {
2068
2145
  await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas add <beta-name>");
2069
2146
  return;
2070
2147
  }
2148
+ const beta = resolveBetaShortcut(betaInput);
2071
2149
  const fresh = loadConfigFresh();
2072
2150
  const current = fresh.custom_betas || [];
2073
2151
  if (current.includes(beta)) {
@@ -2076,16 +2154,21 @@ export async function AnthropicAuthPlugin({ client }) {
2076
2154
  }
2077
2155
  saveConfig({ custom_betas: [...current, beta] });
2078
2156
  Object.assign(config, loadConfigFresh());
2079
- await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nAdded: ${beta}`);
2157
+ const fromShortcut = beta !== betaInput;
2158
+ await sendCommandMessage(
2159
+ input.sessionID,
2160
+ `▣ Anthropic Betas\n\nAdded: ${beta}${fromShortcut ? ` (from shortcut: ${betaInput})` : ""}`,
2161
+ );
2080
2162
  return;
2081
2163
  }
2082
2164
 
2083
2165
  if (action === "remove" || action === "rm") {
2084
- const beta = args[2]?.trim();
2085
- if (!beta) {
2166
+ const betaInput = args[2]?.trim();
2167
+ if (!betaInput) {
2086
2168
  await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas remove <beta-name>");
2087
2169
  return;
2088
2170
  }
2171
+ const beta = resolveBetaShortcut(betaInput);
2089
2172
  const fresh = loadConfigFresh();
2090
2173
  const current = fresh.custom_betas || [];
2091
2174
  if (!current.includes(beta)) {
@@ -2164,7 +2247,7 @@ export async function AnthropicAuthPlugin({ client }) {
2164
2247
  }
2165
2248
  return {
2166
2249
  authorization: `Bearer ${tok}`,
2167
- "anthropic-beta": "files-api-2025-04-14",
2250
+ "anthropic-beta": "oauth-2025-04-20,files-api-2025-04-14",
2168
2251
  };
2169
2252
  }
2170
2253
 
@@ -2291,7 +2374,7 @@ export async function AnthropicAuthPlugin({ client }) {
2291
2374
  method: "POST",
2292
2375
  headers: {
2293
2376
  authorization: authHeaders.authorization,
2294
- "anthropic-beta": "files-api-2025-04-14",
2377
+ "anthropic-beta": "oauth-2025-04-20,files-api-2025-04-14",
2295
2378
  },
2296
2379
  body: form,
2297
2380
  });
package/lib/oauth.mjs CHANGED
@@ -5,6 +5,14 @@ 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
+ const OAUTH_CLI_VERSION_PROFILE = "2.1.79";
14
+ const OAUTH_USER_AGENT = `claude-cli/${OAUTH_CLI_VERSION_PROFILE} (external, cli)`;
15
+
8
16
  function base64url(input) {
9
17
  return Buffer.from(input).toString("base64url");
10
18
  }
@@ -23,11 +31,11 @@ function generatePKCE() {
23
31
  export async function authorize(mode) {
24
32
  const pkce = generatePKCE();
25
33
 
26
- const url = new URL(`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`);
34
+ const url = new URL(`https://${mode === "console" ? OAUTH_CONSOLE_HOST : OAUTH_MAX_HOST}/oauth/authorize`);
27
35
  url.searchParams.set("code", "true");
28
36
  url.searchParams.set("client_id", CLIENT_ID);
29
37
  url.searchParams.set("response_type", "code");
30
- url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback");
38
+ url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
31
39
  url.searchParams.set("scope", "org:create_api_key user:profile user:inference");
32
40
  url.searchParams.set("code_challenge", pkce.challenge);
33
41
  url.searchParams.set("code_challenge_method", "S256");
@@ -42,28 +50,79 @@ export async function authorize(mode) {
42
50
  * Exchange an authorization code for tokens.
43
51
  * @param {string} code
44
52
  * @param {string} verifier
45
- * @returns {Promise<{type: "success", refresh: string, access: string, expires: number, email?: string} | {type: "failed"}>}
53
+ * @returns {Promise<
54
+ * | { type: "success", refresh: string, access: string, expires: number, email?: string }
55
+ * | { type: "failed", status?: number, code?: string, reason?: string, details?: string }
56
+ * >}
46
57
  */
47
58
  export async function exchange(code, verifier) {
48
- const splits = code.split("#");
49
- const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
50
- method: "POST",
51
- headers: {
52
- "Content-Type": "application/json",
53
- },
54
- body: JSON.stringify({
55
- code: splits[0],
56
- state: splits[1],
57
- grant_type: "authorization_code",
58
- client_id: CLIENT_ID,
59
- redirect_uri: "https://console.anthropic.com/oauth/code/callback",
60
- code_verifier: verifier,
61
- }),
62
- });
63
- if (!result.ok)
59
+ const fail = (status, rawText = "") => {
60
+ let errorCode;
61
+ let reason;
62
+
63
+ if (rawText) {
64
+ try {
65
+ const parsed = JSON.parse(rawText);
66
+ if (typeof parsed.error === "string" && parsed.error) {
67
+ errorCode = parsed.error;
68
+ }
69
+ if (typeof parsed.error_description === "string" && parsed.error_description) {
70
+ reason = parsed.error_description;
71
+ } else if (typeof parsed.message === "string" && parsed.message) {
72
+ reason = parsed.message;
73
+ }
74
+ } catch {
75
+ reason = rawText.trim() || undefined;
76
+ }
77
+ }
78
+
79
+ const detailsParts = [];
80
+ if (typeof status === "number") detailsParts.push(`HTTP ${status}`);
81
+ if (errorCode) detailsParts.push(errorCode);
82
+ if (reason) detailsParts.push(reason);
83
+
64
84
  return {
65
85
  type: "failed",
86
+ ...(typeof status === "number" ? { status } : {}),
87
+ ...(errorCode ? { code: errorCode } : {}),
88
+ ...(reason ? { reason } : {}),
89
+ ...(detailsParts.length ? { details: detailsParts.join(" · ") } : {}),
66
90
  };
91
+ };
92
+
93
+ const splits = code.split("#");
94
+ let result;
95
+ try {
96
+ result = await fetch(OAUTH_TOKEN_URL, {
97
+ method: "POST",
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ "User-Agent": OAUTH_USER_AGENT,
101
+ },
102
+ body: JSON.stringify({
103
+ code: splits[0],
104
+ state: splits[1],
105
+ grant_type: "authorization_code",
106
+ client_id: CLIENT_ID,
107
+ redirect_uri: OAUTH_REDIRECT_URI,
108
+ code_verifier: verifier,
109
+ }),
110
+ });
111
+ } catch (err) {
112
+ return fail(undefined, err instanceof Error ? err.message : String(err));
113
+ }
114
+
115
+ if (!result.ok) {
116
+ const raw =
117
+ typeof result.text === "function"
118
+ ? await result
119
+ .text()
120
+ .then((value) => (typeof value === "string" ? value : ""))
121
+ .catch(() => "")
122
+ : "";
123
+ return fail(result.status, raw);
124
+ }
125
+
67
126
  const json = await result.json();
68
127
  return {
69
128
  type: "success",
@@ -86,9 +145,12 @@ export async function exchange(code, verifier) {
86
145
  */
87
146
  export async function revoke(refreshToken) {
88
147
  try {
89
- const resp = await fetch("https://console.anthropic.com/v1/oauth/revoke", {
148
+ const resp = await fetch(OAUTH_REVOKE_URL, {
90
149
  method: "POST",
91
- headers: { "Content-Type": "application/json" },
150
+ headers: {
151
+ "Content-Type": "application/json",
152
+ "User-Agent": OAUTH_USER_AGENT,
153
+ },
92
154
  body: JSON.stringify({
93
155
  token: refreshToken,
94
156
  token_type_hint: "refresh_token",
@@ -110,9 +172,12 @@ export async function revoke(refreshToken) {
110
172
  * @throws {Error} On HTTP errors or network failures
111
173
  */
112
174
  export async function refreshToken(refreshTokenValue, options = {}) {
113
- const resp = await fetch("https://console.anthropic.com/v1/oauth/token", {
175
+ const resp = await fetch(OAUTH_TOKEN_URL, {
114
176
  method: "POST",
115
- headers: { "Content-Type": "application/json" },
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ "User-Agent": OAUTH_USER_AGENT,
180
+ },
116
181
  body: JSON.stringify({
117
182
  grant_type: "refresh_token",
118
183
  client_id: CLIENT_ID,
@@ -0,0 +1,49 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
2
+ import { exchange, refreshToken, revoke } from "./oauth.mjs";
3
+
4
+ const mockFetch = vi.fn();
5
+
6
+ describe("oauth headers", () => {
7
+ beforeEach(() => {
8
+ vi.resetAllMocks();
9
+ vi.stubGlobal("fetch", mockFetch);
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ });
15
+
16
+ it("sends Claude Code user-agent on token exchange", async () => {
17
+ mockFetch.mockResolvedValueOnce({
18
+ ok: false,
19
+ status: 400,
20
+ text: async () => "",
21
+ });
22
+
23
+ await exchange("code#state", "verifier");
24
+
25
+ const [, init] = mockFetch.mock.calls[0];
26
+ expect(init.headers["User-Agent"]).toBe("claude-cli/2.1.79 (external, cli)");
27
+ });
28
+
29
+ it("sends Claude Code user-agent on token refresh", async () => {
30
+ mockFetch.mockResolvedValueOnce({
31
+ ok: true,
32
+ json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
33
+ });
34
+
35
+ await refreshToken("refresh-token");
36
+
37
+ const [, init] = mockFetch.mock.calls[0];
38
+ expect(init.headers["User-Agent"]).toBe("claude-cli/2.1.79 (external, cli)");
39
+ });
40
+
41
+ it("sends Claude Code user-agent on token revoke", async () => {
42
+ mockFetch.mockResolvedValueOnce({ ok: true });
43
+
44
+ await revoke("refresh-token");
45
+
46
+ const [, init] = mockFetch.mock.calls[0];
47
+ expect(init.headers["User-Agent"]).toBe("claude-cli/2.1.79 (external, cli)");
48
+ });
49
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "main": "./index.mjs",
5
5
  "files": [
6
6
  "index.mjs",