opencode-anthropic-fix 0.0.18 → 0.0.20
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 +64 -15
- package/index.mjs +192 -18
- package/lib/config.mjs +50 -5
- package/lib/config.test.mjs +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,8 +43,9 @@ 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
|
+
- **Adaptive thinking for Opus 4.6** — automatically uses `adaptive-thinking-2026-01-28` and maps `budgetTokens` to effort levels (`low`/`medium`/`high`)
|
|
47
47
|
- **1M context limit override** — patches `model.limit.context` so OpenCode compacts at the right threshold while `models.dev` catches up
|
|
48
|
+
- **Runtime config + custom betas** — `/anthropic set`, `/anthropic config`, and `/anthropic betas` slash commands for live feature toggling without restarting OpenCode
|
|
48
49
|
|
|
49
50
|
## Installation
|
|
50
51
|
|
|
@@ -231,20 +232,65 @@ Most commands have short aliases: `ln`, `lo`, `ra`, `rf`, `ls`, `st`, `sw`, `en`
|
|
|
231
232
|
|
|
232
233
|
## Slash Commands in OpenCode
|
|
233
234
|
|
|
234
|
-
The plugin
|
|
235
|
+
The plugin registers a built-in `/anthropic` slash command for account management, feature toggles, and custom beta headers — all without leaving OpenCode.
|
|
235
236
|
|
|
236
|
-
###
|
|
237
|
+
### Account management
|
|
237
238
|
|
|
238
239
|
```text
|
|
239
|
-
/anthropic # list (default)
|
|
240
|
+
/anthropic # list accounts (default)
|
|
240
241
|
/anthropic usage # full account list + quota windows
|
|
241
242
|
/anthropic switch 2
|
|
242
243
|
/anthropic refresh 1
|
|
243
244
|
/anthropic logout 2 # revoke tokens and remove account 2
|
|
244
245
|
/anthropic logout --all # revoke all tokens and clear all accounts
|
|
245
|
-
/anthropic strategy hybrid
|
|
246
246
|
/anthropic stats
|
|
247
|
-
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Runtime configuration
|
|
250
|
+
|
|
251
|
+
Toggle features live without restarting OpenCode. Changes are persisted to `anthropic-auth.json`.
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
/anthropic config # show all current settings
|
|
255
|
+
/anthropic set emulation on # enable/disable Claude signature emulation
|
|
256
|
+
/anthropic set compaction minimal # set prompt compaction mode (minimal/off)
|
|
257
|
+
/anthropic set 1m-context on # enable/disable 1M context limit override
|
|
258
|
+
/anthropic set idle-refresh on # enable/disable idle account refresh
|
|
259
|
+
/anthropic set strategy round-robin # change account selection strategy
|
|
260
|
+
/anthropic set debug on # enable/disable debug logging
|
|
261
|
+
/anthropic set quiet on # suppress non-error toasts
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Custom beta headers
|
|
265
|
+
|
|
266
|
+
Add or remove beta flags that get included in every `anthropic-beta` header. Persisted across sessions.
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
/anthropic betas # show active betas (auto + custom) with presets
|
|
270
|
+
/anthropic betas add <beta-name> # add a custom beta
|
|
271
|
+
/anthropic betas remove <beta-name> # remove a custom beta
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Available preset betas** (shown by `/anthropic betas`):
|
|
275
|
+
|
|
276
|
+
| Beta | Description |
|
|
277
|
+
| --------------------------------- | ------------------------------------------------- |
|
|
278
|
+
| `prompt-caching-scope-2026-01-05` | Cache control with `scope: "org"` — free perf win |
|
|
279
|
+
| `context-management-2025-06-27` | Server-side auto-summarization when context fills |
|
|
280
|
+
| `structured-outputs-2025-12-15` | Strict JSON schema output enforcement |
|
|
281
|
+
| `tool-examples-2025-10-29` | Input/output examples in tool definitions |
|
|
282
|
+
| `code-execution-2025-08-25` | Python sandbox execution by the model |
|
|
283
|
+
| `files-api-2025-04-14` | Upload once, reuse file ID across requests |
|
|
284
|
+
| `compact-2026-01-12` | Conversation compaction endpoint |
|
|
285
|
+
| `mcp-servers-2025-12-04` | MCP servers in API request payload |
|
|
286
|
+
| `web-search-2025-03-05` | Web search (Vertex/Foundry only) |
|
|
287
|
+
|
|
288
|
+
Example workflow:
|
|
289
|
+
|
|
290
|
+
```text
|
|
291
|
+
/anthropic betas add prompt-caching-scope-2026-01-05
|
|
292
|
+
/anthropic betas add context-management-2025-06-27
|
|
293
|
+
/anthropic betas # verify
|
|
248
294
|
```
|
|
249
295
|
|
|
250
296
|
### OAuth flows from slash command
|
|
@@ -272,11 +318,11 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
|
|
|
272
318
|
|
|
273
319
|
Control how the plugin picks which account to use for each request.
|
|
274
320
|
|
|
275
|
-
| Strategy
|
|
276
|
-
|
|
|
277
|
-
| **`
|
|
278
|
-
| **`
|
|
279
|
-
| **`hybrid`**
|
|
321
|
+
| Strategy | Behavior | Best For |
|
|
322
|
+
| --------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
|
323
|
+
| **`round-robin`** (default) | Rotate through accounts on every request | Spreading load evenly across accounts |
|
|
324
|
+
| **`sticky`** | Stay on one account until it fails or is rate-limited | Single account, or when you want predictable behavior |
|
|
325
|
+
| **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
|
|
280
326
|
|
|
281
327
|
### Change Strategy
|
|
282
328
|
|
|
@@ -297,8 +343,8 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
|
|
|
297
343
|
|
|
298
344
|
```jsonc
|
|
299
345
|
{
|
|
300
|
-
// Account selection strategy: "
|
|
301
|
-
"account_selection_strategy": "
|
|
346
|
+
// Account selection strategy: "round-robin" | "sticky" | "hybrid"
|
|
347
|
+
"account_selection_strategy": "round-robin",
|
|
302
348
|
|
|
303
349
|
// Seconds before consecutive failure count resets (60-7200)
|
|
304
350
|
"failure_ttl_seconds": 3600,
|
|
@@ -325,8 +371,8 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
|
|
|
325
371
|
// Only applied for OAuth (Max Plan) sessions — API key users use the
|
|
326
372
|
// context-1m-2025-08-07 beta header instead.
|
|
327
373
|
"override_model_limits": {
|
|
328
|
-
// Enable/disable the override
|
|
329
|
-
"enabled":
|
|
374
|
+
// Enable/disable the override (default: off — enable if you need 1M context)
|
|
375
|
+
"enabled": false,
|
|
330
376
|
// Context window to inject (tokens). Default: 1_000_000.
|
|
331
377
|
"context": 1000000,
|
|
332
378
|
// Max output tokens to inject. 0 = leave the model's default unchanged.
|
|
@@ -351,6 +397,9 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
|
|
|
351
397
|
"initial_tokens": 50,
|
|
352
398
|
},
|
|
353
399
|
|
|
400
|
+
// Custom beta headers (added to every request via /anthropic betas add)
|
|
401
|
+
"custom_betas": [],
|
|
402
|
+
|
|
354
403
|
// Toast notification settings
|
|
355
404
|
"toasts": {
|
|
356
405
|
// Suppress non-error toasts (account status, switching)
|
package/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { AccountManager } from "./lib/accounts.mjs";
|
|
7
7
|
import { authorize, exchange, refreshToken } from "./lib/oauth.mjs";
|
|
8
|
-
import { loadConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
|
|
8
|
+
import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
|
|
9
9
|
import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
|
|
10
10
|
import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
|
|
11
11
|
import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
|
|
@@ -103,7 +103,7 @@ 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.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
109
|
const BILLING_HASH_SALT = "59cf53e54c78";
|
|
@@ -292,7 +292,10 @@ function supportsThinking(model) {
|
|
|
292
292
|
*/
|
|
293
293
|
function isOpus46Model(model) {
|
|
294
294
|
if (!model) return false;
|
|
295
|
-
|
|
295
|
+
// Match standard IDs (claude-opus-4-6, claude-opus-4.6) and Bedrock ARNs
|
|
296
|
+
// (arn:aws:bedrock:...anthropic.claude-opus-4-6-...).
|
|
297
|
+
// Also match bare "opus-4-6" / "opus-4.6" fragments for non-standard strings.
|
|
298
|
+
return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
/**
|
|
@@ -300,7 +303,8 @@ function isOpus46Model(model) {
|
|
|
300
303
|
* @returns {boolean}
|
|
301
304
|
*/
|
|
302
305
|
function hasOneMillionContext(model) {
|
|
303
|
-
|
|
306
|
+
// Models with explicit 1m suffix, or Opus 4.6 (1M by default since v2.1.75).
|
|
307
|
+
return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model) || isOpus46Model(model);
|
|
304
308
|
}
|
|
305
309
|
|
|
306
310
|
/**
|
|
@@ -655,9 +659,10 @@ function buildSystemPromptBlocks(system, signature, messages) {
|
|
|
655
659
|
* @param {boolean} signatureEnabled
|
|
656
660
|
* @param {string} model
|
|
657
661
|
* @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
|
|
662
|
+
* @param {string[]} [customBetas]
|
|
658
663
|
* @returns {string}
|
|
659
664
|
*/
|
|
660
|
-
function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider) {
|
|
665
|
+
function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas) {
|
|
661
666
|
const incomingBetasList = incomingBeta
|
|
662
667
|
.split(",")
|
|
663
668
|
.map((b) => b.trim())
|
|
@@ -725,6 +730,10 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
|
|
|
725
730
|
|
|
726
731
|
betas.push("fine-grained-tool-streaming-2025-05-14");
|
|
727
732
|
|
|
733
|
+
if (Array.isArray(customBetas)) {
|
|
734
|
+
betas.push(...customBetas.filter(Boolean));
|
|
735
|
+
}
|
|
736
|
+
|
|
728
737
|
const mergedBetas = [...new Set([...betas, ...incomingBetasList])];
|
|
729
738
|
if (provider === "bedrock") {
|
|
730
739
|
return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
|
|
@@ -732,7 +741,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
|
|
|
732
741
|
return mergedBetas.join(",");
|
|
733
742
|
}
|
|
734
743
|
|
|
735
|
-
/** @typedef {'low' | 'medium' | 'high'
|
|
744
|
+
/** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
|
|
736
745
|
|
|
737
746
|
/**
|
|
738
747
|
* Map budgetTokens to an adaptive thinking effort level.
|
|
@@ -743,8 +752,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
|
|
|
743
752
|
function budgetTokensToEffort(budgetTokens) {
|
|
744
753
|
if (budgetTokens <= 1024) return "low";
|
|
745
754
|
if (budgetTokens <= 8000) return "medium";
|
|
746
|
-
|
|
747
|
-
return "max";
|
|
755
|
+
return "high";
|
|
748
756
|
}
|
|
749
757
|
|
|
750
758
|
/**
|
|
@@ -753,7 +761,7 @@ function budgetTokensToEffort(budgetTokens) {
|
|
|
753
761
|
* @returns {value is ThinkingEffort}
|
|
754
762
|
*/
|
|
755
763
|
function isValidEffort(value) {
|
|
756
|
-
return value === "low" || value === "medium" || value === "high"
|
|
764
|
+
return value === "low" || value === "medium" || value === "high";
|
|
757
765
|
}
|
|
758
766
|
|
|
759
767
|
/**
|
|
@@ -884,7 +892,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
|
|
|
884
892
|
const incomingBeta = requestHeaders.get("anthropic-beta") || "";
|
|
885
893
|
const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
|
|
886
894
|
const provider = detectProvider(requestUrl);
|
|
887
|
-
const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider);
|
|
895
|
+
const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider, signature.customBetas);
|
|
888
896
|
|
|
889
897
|
const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
890
898
|
const bearerToken = authTokenOverride || accessToken;
|
|
@@ -1427,10 +1435,14 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
|
|
|
1427
1435
|
* @param {import('./lib/accounts.mjs').ManagedAccount} account
|
|
1428
1436
|
* @param {ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient>} client
|
|
1429
1437
|
* @param {"foreground" | "idle"} [source]
|
|
1438
|
+
* @param {{ onTokensUpdated?: () => Promise<void> }} [options] - If provided,
|
|
1439
|
+
* called under the cross-process lock after token update to persist rotated
|
|
1440
|
+
* tokens before the lock is released. Omitting means tokens won't be saved
|
|
1441
|
+
* to disk until the caller arranges it (risking the rotation race).
|
|
1430
1442
|
* @returns {Promise<string>} The new access token
|
|
1431
1443
|
* @throws {Error} If refresh fails
|
|
1432
1444
|
*/
|
|
1433
|
-
async function refreshAccountToken(account, client, source = "foreground") {
|
|
1445
|
+
async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
|
|
1434
1446
|
const lockResult = await acquireRefreshLock(account.id, {
|
|
1435
1447
|
timeoutMs: 2_000,
|
|
1436
1448
|
backoffMs: 60,
|
|
@@ -1471,6 +1483,22 @@ async function refreshAccountToken(account, client, source = "foreground") {
|
|
|
1471
1483
|
}
|
|
1472
1484
|
markTokenStateUpdated(account);
|
|
1473
1485
|
|
|
1486
|
+
// Persist new tokens to disk BEFORE releasing the cross-process lock.
|
|
1487
|
+
// This is critical: if we release the lock first, another process can
|
|
1488
|
+
// acquire it and read the old (now-rotated) refresh token from disk,
|
|
1489
|
+
// leading to an invalid_grant failure. The debounced requestSaveToDisk()
|
|
1490
|
+
// that callers used previously left a ~1 s window where this race could
|
|
1491
|
+
// (and did) happen.
|
|
1492
|
+
if (onTokensUpdated) {
|
|
1493
|
+
try {
|
|
1494
|
+
await onTokensUpdated();
|
|
1495
|
+
} catch {
|
|
1496
|
+
// Best-effort: in-memory tokens remain valid for this process.
|
|
1497
|
+
// The callback is responsible for scheduling its own fallback
|
|
1498
|
+
// (e.g. a debounced retry) if the synchronous save fails.
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1474
1502
|
// Also persist to OpenCode's auth.json for compatibility.
|
|
1475
1503
|
// This should be best-effort: a persistence hiccup should not invalidate an
|
|
1476
1504
|
// otherwise successful refresh token exchange.
|
|
@@ -1877,6 +1905,140 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
1877
1905
|
return;
|
|
1878
1906
|
}
|
|
1879
1907
|
|
|
1908
|
+
// /anthropic config — show effective config
|
|
1909
|
+
if (primary === "config") {
|
|
1910
|
+
const fresh = loadConfigFresh();
|
|
1911
|
+
const lines = [
|
|
1912
|
+
"▣ Anthropic Config",
|
|
1913
|
+
"",
|
|
1914
|
+
`strategy: ${fresh.account_selection_strategy}`,
|
|
1915
|
+
`emulation: ${fresh.signature_emulation.enabled ? "on" : "off"}`,
|
|
1916
|
+
`compaction: ${fresh.signature_emulation.prompt_compaction}`,
|
|
1917
|
+
`1m-context: ${fresh.override_model_limits.enabled ? "on" : "off"}`,
|
|
1918
|
+
`idle-refresh: ${fresh.idle_refresh.enabled ? "on" : "off"}`,
|
|
1919
|
+
`debug: ${fresh.debug ? "on" : "off"}`,
|
|
1920
|
+
`quiet: ${fresh.toasts.quiet ? "on" : "off"}`,
|
|
1921
|
+
`custom_betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
|
|
1922
|
+
];
|
|
1923
|
+
await sendCommandMessage(input.sessionID, lines.join("\n"));
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// /anthropic set <key> <value> — toggle features at runtime
|
|
1928
|
+
if (primary === "set") {
|
|
1929
|
+
const key = (args[1] || "").toLowerCase();
|
|
1930
|
+
const value = (args[2] || "").toLowerCase();
|
|
1931
|
+
/** @type {Record<string, () => void>} */
|
|
1932
|
+
const setters = {
|
|
1933
|
+
emulation: () =>
|
|
1934
|
+
saveConfig({ signature_emulation: { enabled: value === "on" || value === "1" || value === "true" } }),
|
|
1935
|
+
compaction: () =>
|
|
1936
|
+
saveConfig({ signature_emulation: { prompt_compaction: value === "off" ? "off" : "minimal" } }),
|
|
1937
|
+
"1m-context": () =>
|
|
1938
|
+
saveConfig({ override_model_limits: { enabled: value === "on" || value === "1" || value === "true" } }),
|
|
1939
|
+
"idle-refresh": () =>
|
|
1940
|
+
saveConfig({ idle_refresh: { enabled: value === "on" || value === "1" || value === "true" } }),
|
|
1941
|
+
debug: () => saveConfig({ debug: value === "on" || value === "1" || value === "true" }),
|
|
1942
|
+
quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
|
|
1943
|
+
strategy: () => {
|
|
1944
|
+
const valid = ["sticky", "round-robin", "hybrid"];
|
|
1945
|
+
if (valid.includes(value)) saveConfig({ account_selection_strategy: value });
|
|
1946
|
+
else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
|
|
1947
|
+
},
|
|
1948
|
+
};
|
|
1949
|
+
|
|
1950
|
+
if (!key || !setters[key]) {
|
|
1951
|
+
const keys = Object.keys(setters).join(", ");
|
|
1952
|
+
await sendCommandMessage(
|
|
1953
|
+
input.sessionID,
|
|
1954
|
+
`▣ Anthropic Set\n\nUsage: /anthropic set <key> <value>\nKeys: ${keys}\nValues: on/off (or specific values for strategy/compaction)`,
|
|
1955
|
+
);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
if (!value) {
|
|
1959
|
+
await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\nMissing value for "${key}".`);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
setters[key]();
|
|
1963
|
+
// Reload config into runtime
|
|
1964
|
+
Object.assign(config, loadConfigFresh());
|
|
1965
|
+
await sendCommandMessage(input.sessionID, `▣ Anthropic Set\n\n${key} = ${value}`);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// /anthropic betas [add|remove <beta>] — show/manage custom betas
|
|
1970
|
+
if (primary === "betas") {
|
|
1971
|
+
const action = (args[1] || "").toLowerCase();
|
|
1972
|
+
|
|
1973
|
+
if (!action || action === "list") {
|
|
1974
|
+
const fresh = loadConfigFresh();
|
|
1975
|
+
const lines = [
|
|
1976
|
+
"▣ Anthropic Betas",
|
|
1977
|
+
"",
|
|
1978
|
+
"Preset betas (auto-computed per model/provider):",
|
|
1979
|
+
" oauth-2025-04-20, claude-code-20250219,",
|
|
1980
|
+
" interleaved-thinking-2025-05-14 OR adaptive-thinking-2026-01-28,",
|
|
1981
|
+
" fine-grained-tool-streaming-2025-05-14",
|
|
1982
|
+
"",
|
|
1983
|
+
`Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
|
|
1984
|
+
"",
|
|
1985
|
+
"Toggleable presets:",
|
|
1986
|
+
" /anthropic betas add structured-outputs-2025-12-15",
|
|
1987
|
+
" /anthropic betas add context-management-2025-06-27",
|
|
1988
|
+
" /anthropic betas add prompt-caching-scope-2026-01-05",
|
|
1989
|
+
" /anthropic betas add tool-examples-2025-10-29",
|
|
1990
|
+
" /anthropic betas add web-search-2025-03-05",
|
|
1991
|
+
" /anthropic betas add code-execution-2025-08-25",
|
|
1992
|
+
" /anthropic betas add compact-2026-01-12",
|
|
1993
|
+
" /anthropic betas add mcp-servers-2025-12-04",
|
|
1994
|
+
" /anthropic betas add files-api-2025-04-14",
|
|
1995
|
+
"",
|
|
1996
|
+
"Remove: /anthropic betas remove <beta>",
|
|
1997
|
+
];
|
|
1998
|
+
await sendCommandMessage(input.sessionID, lines.join("\n"));
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
if (action === "add") {
|
|
2003
|
+
const beta = args[2]?.trim();
|
|
2004
|
+
if (!beta) {
|
|
2005
|
+
await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas add <beta-name>");
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const fresh = loadConfigFresh();
|
|
2009
|
+
const current = fresh.custom_betas || [];
|
|
2010
|
+
if (current.includes(beta)) {
|
|
2011
|
+
await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" already added.`);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
saveConfig({ custom_betas: [...current, beta] });
|
|
2015
|
+
Object.assign(config, loadConfigFresh());
|
|
2016
|
+
await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nAdded: ${beta}`);
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
if (action === "remove" || action === "rm") {
|
|
2021
|
+
const beta = args[2]?.trim();
|
|
2022
|
+
if (!beta) {
|
|
2023
|
+
await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas remove <beta-name>");
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
const fresh = loadConfigFresh();
|
|
2027
|
+
const current = fresh.custom_betas || [];
|
|
2028
|
+
if (!current.includes(beta)) {
|
|
2029
|
+
await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\n"${beta}" not in custom betas.`);
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
saveConfig({ custom_betas: current.filter((b) => b !== beta) });
|
|
2033
|
+
Object.assign(config, loadConfigFresh());
|
|
2034
|
+
await sendCommandMessage(input.sessionID, `▣ Anthropic Betas\n\nRemoved: ${beta}`);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
await sendCommandMessage(input.sessionID, "▣ Anthropic Betas\n\nUsage: /anthropic betas [add|remove <beta>]");
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1880
2042
|
// Interactive CLI command is not compatible with slash flow.
|
|
1881
2043
|
if (primary === "manage" || primary === "mg") {
|
|
1882
2044
|
await sendCommandMessage(
|
|
@@ -2011,7 +2173,21 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2011
2173
|
const entry = { source, promise: Promise.resolve("") };
|
|
2012
2174
|
const p = (async () => {
|
|
2013
2175
|
try {
|
|
2014
|
-
return await refreshAccountToken(account, client, source
|
|
2176
|
+
return await refreshAccountToken(account, client, source, {
|
|
2177
|
+
onTokensUpdated: async () => {
|
|
2178
|
+
try {
|
|
2179
|
+
await accountManager.saveToDisk();
|
|
2180
|
+
} catch {
|
|
2181
|
+
// Synchronous save failed (disk full, permissions, etc.).
|
|
2182
|
+
// Schedule a debounced retry so the rotated token eventually
|
|
2183
|
+
// reaches disk. Another process may hit invalid_grant in the
|
|
2184
|
+
// interim, but its retry-from-disk logic can recover once this
|
|
2185
|
+
// save lands.
|
|
2186
|
+
accountManager.requestSaveToDisk();
|
|
2187
|
+
throw new Error("save failed, debounced retry scheduled");
|
|
2188
|
+
}
|
|
2189
|
+
},
|
|
2190
|
+
});
|
|
2015
2191
|
} finally {
|
|
2016
2192
|
if (refreshInFlight.get(key) === entry) {
|
|
2017
2193
|
refreshInFlight.delete(key);
|
|
@@ -2040,7 +2216,6 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2040
2216
|
try {
|
|
2041
2217
|
try {
|
|
2042
2218
|
await refreshAccountTokenSingleFlight(account, "idle");
|
|
2043
|
-
accountManager.requestSaveToDisk();
|
|
2044
2219
|
return;
|
|
2045
2220
|
} catch (err) {
|
|
2046
2221
|
let details = parseRefreshFailure(err);
|
|
@@ -2068,7 +2243,6 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2068
2243
|
|
|
2069
2244
|
try {
|
|
2070
2245
|
await refreshAccountTokenSingleFlight(account, "idle");
|
|
2071
|
-
accountManager.requestSaveToDisk();
|
|
2072
2246
|
return;
|
|
2073
2247
|
} catch (retryErr) {
|
|
2074
2248
|
details = parseRefreshFailure(retryErr);
|
|
@@ -2125,7 +2299,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2125
2299
|
input.command ??= {};
|
|
2126
2300
|
input.command["anthropic"] = {
|
|
2127
2301
|
template: "/anthropic",
|
|
2128
|
-
description: "Manage Anthropic
|
|
2302
|
+
description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)",
|
|
2129
2303
|
};
|
|
2130
2304
|
},
|
|
2131
2305
|
"command.execute.before": async (input) => {
|
|
@@ -2252,8 +2426,8 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2252
2426
|
const attemptedRefreshToken = account.refreshToken;
|
|
2253
2427
|
try {
|
|
2254
2428
|
accessToken = await refreshAccountTokenSingleFlight(account);
|
|
2255
|
-
//
|
|
2256
|
-
|
|
2429
|
+
// Tokens are now saved under the refresh lock (inside
|
|
2430
|
+
// refreshAccountToken) so no debounced save needed here.
|
|
2257
2431
|
} catch (err) {
|
|
2258
2432
|
// Token refresh failed — check if another instance rotated the
|
|
2259
2433
|
// refresh token and persisted it between attempts.
|
|
@@ -2287,7 +2461,6 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2287
2461
|
|
|
2288
2462
|
try {
|
|
2289
2463
|
accessToken = await refreshAccountTokenSingleFlight(account);
|
|
2290
|
-
accountManager.requestSaveToDisk();
|
|
2291
2464
|
} catch (retryErr) {
|
|
2292
2465
|
finalError = retryErr;
|
|
2293
2466
|
details = parseRefreshFailure(retryErr);
|
|
@@ -2354,6 +2527,7 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2354
2527
|
const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
|
|
2355
2528
|
enabled: signatureEmulationEnabled,
|
|
2356
2529
|
claudeCliVersion,
|
|
2530
|
+
customBetas: config.custom_betas,
|
|
2357
2531
|
});
|
|
2358
2532
|
// Execute the request
|
|
2359
2533
|
let response;
|
package/lib/config.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { homedir } from "node:os";
|
|
|
51
51
|
* @property {boolean} debug
|
|
52
52
|
* @property {{ enabled: boolean, fetch_claude_code_version_on_startup: boolean, prompt_compaction: 'minimal' | 'off' }} signature_emulation
|
|
53
53
|
* @property {OverrideModelLimitsConfig} override_model_limits
|
|
54
|
+
* @property {string[]} custom_betas
|
|
54
55
|
* @property {HealthScoreConfig} health_score
|
|
55
56
|
* @property {TokenBucketConfig} token_bucket
|
|
56
57
|
* @property {ToastConfig} toasts
|
|
@@ -60,7 +61,7 @@ import { homedir } from "node:os";
|
|
|
60
61
|
|
|
61
62
|
/** @type {AnthropicAuthConfig} */
|
|
62
63
|
export const DEFAULT_CONFIG = {
|
|
63
|
-
account_selection_strategy: "
|
|
64
|
+
account_selection_strategy: "round-robin",
|
|
64
65
|
failure_ttl_seconds: 3600,
|
|
65
66
|
debug: false,
|
|
66
67
|
signature_emulation: {
|
|
@@ -69,10 +70,11 @@ export const DEFAULT_CONFIG = {
|
|
|
69
70
|
prompt_compaction: "minimal",
|
|
70
71
|
},
|
|
71
72
|
override_model_limits: {
|
|
72
|
-
enabled:
|
|
73
|
+
enabled: false,
|
|
73
74
|
context: 1_000_000,
|
|
74
75
|
output: 0,
|
|
75
76
|
},
|
|
77
|
+
custom_betas: [],
|
|
76
78
|
health_score: {
|
|
77
79
|
initial: 70,
|
|
78
80
|
success_reward: 1,
|
|
@@ -112,6 +114,7 @@ function createDefaultConfig() {
|
|
|
112
114
|
...DEFAULT_CONFIG,
|
|
113
115
|
signature_emulation: { ...DEFAULT_CONFIG.signature_emulation },
|
|
114
116
|
override_model_limits: { ...DEFAULT_CONFIG.override_model_limits },
|
|
117
|
+
custom_betas: [...DEFAULT_CONFIG.custom_betas],
|
|
115
118
|
health_score: { ...DEFAULT_CONFIG.health_score },
|
|
116
119
|
token_bucket: { ...DEFAULT_CONFIG.token_bucket },
|
|
117
120
|
toasts: { ...DEFAULT_CONFIG.toasts },
|
|
@@ -197,6 +200,11 @@ function validateConfig(raw) {
|
|
|
197
200
|
};
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
// Custom betas
|
|
204
|
+
if (Array.isArray(raw.custom_betas)) {
|
|
205
|
+
config.custom_betas = raw.custom_betas.filter((b) => typeof b === "string" && b.trim()).map((b) => b.trim());
|
|
206
|
+
}
|
|
207
|
+
|
|
200
208
|
// Health score sub-config
|
|
201
209
|
if (raw.health_score && typeof raw.health_score === "object") {
|
|
202
210
|
const hs = /** @type {Record<string, unknown>} */ (raw.health_score);
|
|
@@ -408,19 +416,56 @@ export function loadRawConfig() {
|
|
|
408
416
|
* Uses atomic write (temp + rename) for safety.
|
|
409
417
|
* @param {Record<string, unknown>} updates - Keys to merge into the config file
|
|
410
418
|
*/
|
|
419
|
+
/**
|
|
420
|
+
* Deep merge source into target (one level deep for objects, replace for arrays/primitives).
|
|
421
|
+
* @param {Record<string, unknown>} target
|
|
422
|
+
* @param {Record<string, unknown>} source
|
|
423
|
+
* @returns {Record<string, unknown>}
|
|
424
|
+
*/
|
|
425
|
+
function deepMergeConfig(target, source) {
|
|
426
|
+
const result = { ...target };
|
|
427
|
+
for (const [key, value] of Object.entries(source)) {
|
|
428
|
+
if (
|
|
429
|
+
value &&
|
|
430
|
+
typeof value === "object" &&
|
|
431
|
+
!Array.isArray(value) &&
|
|
432
|
+
typeof result[key] === "object" &&
|
|
433
|
+
result[key] &&
|
|
434
|
+
!Array.isArray(result[key])
|
|
435
|
+
) {
|
|
436
|
+
result[key] = {
|
|
437
|
+
.../** @type {Record<string, unknown>} */ (result[key]),
|
|
438
|
+
.../** @type {Record<string, unknown>} */ (value),
|
|
439
|
+
};
|
|
440
|
+
} else {
|
|
441
|
+
result[key] = value;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
|
|
411
447
|
export function saveConfig(updates) {
|
|
412
448
|
const configPath = getConfigPath();
|
|
413
449
|
const dir = dirname(configPath);
|
|
414
450
|
mkdirSync(dir, { recursive: true });
|
|
415
451
|
|
|
416
|
-
//
|
|
452
|
+
// Re-read from disk to avoid clobbering concurrent writes
|
|
417
453
|
const existing = loadRawConfig();
|
|
418
454
|
|
|
419
|
-
//
|
|
420
|
-
const merged =
|
|
455
|
+
// Deep merge (one level) so sub-config objects are merged, not replaced
|
|
456
|
+
const merged = deepMergeConfig(existing, updates);
|
|
421
457
|
|
|
422
458
|
// Atomic write: temp file + rename
|
|
423
459
|
const tmpPath = configPath + `.tmp.${process.pid}`;
|
|
424
460
|
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
425
461
|
renameSync(tmpPath, configPath);
|
|
426
462
|
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Load config fresh from disk (bypassing any startup cache).
|
|
466
|
+
* Useful for slash commands that need current on-disk state.
|
|
467
|
+
* @returns {AnthropicAuthConfig}
|
|
468
|
+
*/
|
|
469
|
+
export function loadConfigFresh() {
|
|
470
|
+
return loadConfig();
|
|
471
|
+
}
|
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("round-robin");
|
|
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("round-robin");
|
|
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("round-robin");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
it("enables debug from OPENCODE_ANTHROPIC_DEBUG=1", () => {
|