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 +13 -7
- package/index.mjs +132 -49
- package/lib/oauth.mjs +88 -23
- package/lib/oauth.test.mjs +49 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
|
50
|
-
- **Code execution support** —
|
|
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
|
|
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 | **
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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 {
|
|
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
|
|
2043
|
-
"
|
|
2044
|
-
|
|
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
|
|
2067
|
-
if (!
|
|
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
|
-
|
|
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
|
|
2085
|
-
if (!
|
|
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" ?
|
|
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",
|
|
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<
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
148
|
+
const resp = await fetch(OAUTH_REVOKE_URL, {
|
|
90
149
|
method: "POST",
|
|
91
|
-
headers: {
|
|
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(
|
|
175
|
+
const resp = await fetch(OAUTH_TOKEN_URL, {
|
|
114
176
|
method: "POST",
|
|
115
|
-
headers: {
|
|
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
|
+
});
|