opencode-anthropic-fix 0.0.25 → 0.0.26

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/index.mjs CHANGED
@@ -9,7 +9,13 @@ import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from
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";
12
- import { isAccountSpecificError, parseRateLimitReason, parseRetryAfterHeader } from "./lib/backoff.mjs";
12
+ import {
13
+ isAccountSpecificError,
14
+ parseRateLimitReason,
15
+ parseRetryAfterHeader,
16
+ parseRetryAfterMsHeader,
17
+ parseShouldRetryHeader,
18
+ } from "./lib/backoff.mjs";
13
19
 
14
20
  // ---------------------------------------------------------------------------
15
21
  // Account management CLI prompts
@@ -99,15 +105,300 @@ async function promptManageAccounts(accountManager) {
99
105
  }
100
106
  }
101
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Session-level cache & cost tracking (Phase 4)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, recentCacheRates: number[], sessionCostUsd: number}} */
113
+ const sessionMetrics = {
114
+ turns: 0,
115
+ totalInput: 0,
116
+ totalOutput: 0,
117
+ totalCacheRead: 0,
118
+ totalCacheWrite: 0,
119
+ recentCacheRates: [], // rolling window of last 5 turns
120
+ sessionCostUsd: 0,
121
+ };
122
+
123
+ const MODEL_PRICING = {
124
+ "claude-opus-4-6": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
125
+ "claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
126
+ "claude-haiku-4-5": { input: 0.8, output: 4.0, cacheRead: 0.08, cacheWrite: 1.0 },
127
+ };
128
+ const DEFAULT_PRICING = MODEL_PRICING["claude-sonnet-4-6"];
129
+
130
+ /**
131
+ * Get pricing for a model, falling back to sonnet pricing for unknown models.
132
+ * @param {string} model
133
+ * @returns {{input: number, output: number, cacheRead: number, cacheWrite: number}}
134
+ */
135
+ function getModelPricing(model) {
136
+ if (!model) return DEFAULT_PRICING;
137
+ // Prefix match: "claude-opus-4-6-20260101" matches "claude-opus-4-6"
138
+ for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
139
+ if (model.startsWith(key)) return pricing;
140
+ }
141
+ return DEFAULT_PRICING;
142
+ }
143
+
144
+ /**
145
+ * Calculate the cost in USD for a set of token counts.
146
+ * @param {UsageStats} usage
147
+ * @param {string} model
148
+ * @returns {number}
149
+ */
150
+ function calculateCostUsd(usage, model) {
151
+ const p = getModelPricing(model);
152
+ return (
153
+ (usage.inputTokens / 1_000_000) * p.input +
154
+ (usage.outputTokens / 1_000_000) * p.output +
155
+ (usage.cacheReadTokens / 1_000_000) * p.cacheRead +
156
+ (usage.cacheWriteTokens / 1_000_000) * p.cacheWrite
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Update session metrics after a completed turn.
162
+ * @param {UsageStats} usage
163
+ * @param {string} model
164
+ */
165
+ function updateSessionMetrics(usage, model) {
166
+ sessionMetrics.turns += 1;
167
+ sessionMetrics.totalInput += usage.inputTokens;
168
+ sessionMetrics.totalOutput += usage.outputTokens;
169
+ sessionMetrics.totalCacheRead += usage.cacheReadTokens;
170
+ sessionMetrics.totalCacheWrite += usage.cacheWriteTokens;
171
+
172
+ // Cache hit rate for this turn
173
+ const totalPrompt = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
174
+ const hitRate = totalPrompt > 0 ? usage.cacheReadTokens / totalPrompt : 0;
175
+ sessionMetrics.recentCacheRates.push(hitRate);
176
+ if (sessionMetrics.recentCacheRates.length > 5) {
177
+ sessionMetrics.recentCacheRates.shift();
178
+ }
179
+
180
+ // Cost
181
+ sessionMetrics.sessionCostUsd += calculateCostUsd(usage, model);
182
+ }
183
+
184
+ /**
185
+ * Get rolling average cache hit rate over last 5 turns.
186
+ * @returns {number} 0-1
187
+ */
188
+ function getAverageCacheHitRate() {
189
+ const rates = sessionMetrics.recentCacheRates;
190
+ if (rates.length === 0) return 0;
191
+ return rates.reduce((a, b) => a + b, 0) / rates.length;
192
+ }
193
+
194
+ // --- Phase 5: Auto-strategy adaptation ---
195
+ // strategyState is created per-plugin instance inside AnthropicAuthPlugin() to avoid
196
+ // cross-instance pollution (critical for test isolation and multi-instance scenarios).
197
+ // See createStrategyState() below.
198
+
199
+ // --- Phase 5: Minimal telemetry emulation ("Silent Observer") ---
200
+
201
+ class TelemetryEmitter {
202
+ #enabled = false;
203
+ #sent = false;
204
+ #disabled = false; // permanently disabled for this session (on auth failure)
205
+ #deviceId = null;
206
+ #sessionId = null;
207
+ #cliVersion = null;
208
+ #accountUuid = "";
209
+ #orgUuid = "";
210
+
211
+ constructor() {
212
+ this.#sessionId = randomUUID();
213
+ }
214
+
215
+ /**
216
+ * Initialize with session context. Call once config and accounts are ready.
217
+ * @param {object} opts
218
+ * @param {boolean} opts.enabled
219
+ * @param {string} opts.deviceId
220
+ * @param {string} opts.cliVersion
221
+ * @param {string} [opts.accountUuid]
222
+ * @param {string} [opts.orgUuid]
223
+ * @param {string} [opts.sessionId] - Must match signatureSessionId for correlation
224
+ */
225
+ init({ enabled, deviceId, cliVersion, accountUuid, orgUuid, sessionId }) {
226
+ this.#enabled = enabled;
227
+ this.#deviceId = deviceId;
228
+ this.#cliVersion = cliVersion;
229
+ this.#accountUuid = accountUuid || "";
230
+ this.#orgUuid = orgUuid || "";
231
+ if (sessionId) this.#sessionId = sessionId;
232
+ }
233
+
234
+ /**
235
+ * Build a ClaudeCodeInternalEvent matching the schema from reverse-engineering.
236
+ * @param {string} eventName
237
+ * @param {object} [extras]
238
+ * @returns {object}
239
+ */
240
+ #buildEvent(eventName, extras = {}) {
241
+ return {
242
+ event_type: "ClaudeCodeInternalEvent",
243
+ event_data: {
244
+ event_id: randomUUID(),
245
+ event_name: eventName,
246
+ client_timestamp: new Date().toISOString(),
247
+ device_id: this.#deviceId,
248
+ email: "", // RE doc §7.2 — present but empty (privacy: don't leak email in telemetry)
249
+ auth: {
250
+ account_uuid: this.#accountUuid,
251
+ organization_uuid: this.#orgUuid,
252
+ },
253
+ core: {
254
+ session_id: this.#sessionId,
255
+ model: "", // empty — don't reveal model choice
256
+ user_type: "consumer", // RE doc §7.2 — default consumer for Claude.ai OAuth
257
+ client_type: "cli", // RE doc §7.2 — always cli
258
+ betas: [], // RE doc §7.2 — populated at send time if needed
259
+ is_interactive: true,
260
+ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT || "cli",
261
+ },
262
+ env: {
263
+ platform: process.platform,
264
+ arch: process.arch,
265
+ node_version: process.version,
266
+ terminal: process.env.TERM_PROGRAM || process.env.TERM || "",
267
+ version: this.#cliVersion,
268
+ build_time: "", // omit — we don't know the real build time
269
+ is_ci: false,
270
+ },
271
+ ...extras,
272
+ },
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Send a batch of events to the telemetry endpoint.
278
+ * @param {object[]} events
279
+ * @param {string} accessToken
280
+ * @returns {Promise<boolean>}
281
+ */
282
+ async #sendBatch(events, accessToken) {
283
+ if (!accessToken || events.length === 0) return false;
284
+
285
+ try {
286
+ const response = await fetch("https://api.anthropic.com/api/event_logging/batch", {
287
+ method: "POST",
288
+ headers: {
289
+ "Content-Type": "application/json",
290
+ Authorization: `Bearer ${accessToken}`,
291
+ "anthropic-version": "2023-06-01",
292
+ "User-Agent": `claude-code/${this.#cliVersion}`,
293
+ "x-service-name": "claude-code",
294
+ },
295
+ body: JSON.stringify({ events }),
296
+ signal: AbortSignal.timeout(10000), // 10s timeout
297
+ });
298
+
299
+ if (response.status === 401 || response.status === 403) {
300
+ this.#disabled = true;
301
+ return false;
302
+ }
303
+ if (response.status === 400) {
304
+ this.#disabled = true;
305
+ return false;
306
+ }
307
+ return response.ok;
308
+ } catch {
309
+ // Network error — don't retry, don't disable
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Send startup events after first successful API response.
316
+ * Called once per session with random jitter.
317
+ * @param {string} accessToken
318
+ */
319
+ async sendStartupEvents(accessToken) {
320
+ if (!this.#enabled || this.#sent || this.#disabled) return;
321
+ this.#sent = true;
322
+
323
+ // Random jitter: 500ms - 2000ms after first successful response
324
+ const jitter = 500 + Math.random() * 1500;
325
+ await new Promise((resolve) => setTimeout(resolve, jitter));
326
+
327
+ if (this.#disabled) return;
328
+
329
+ const startedEvent = this.#buildEvent("tengu_started");
330
+ const startupTelemetryEvent = this.#buildEvent("tengu_startup_telemetry", {
331
+ is_git: true,
332
+ sandbox_enabled: false,
333
+ });
334
+
335
+ await this.#sendBatch([startedEvent, startupTelemetryEvent], accessToken);
336
+ }
337
+
338
+ /**
339
+ * Send exit event on shutdown. Best-effort, no retry.
340
+ * @param {string} accessToken
341
+ * @param {number} sessionDurationMs
342
+ */
343
+ async sendExitEvent(accessToken, sessionDurationMs) {
344
+ if (!this.#enabled || !this.#sent || this.#disabled) return;
345
+
346
+ const exitEvent = this.#buildEvent("tengu_exit", {
347
+ last_session_duration: sessionDurationMs,
348
+ last_session_id: this.#sessionId,
349
+ });
350
+
351
+ // Best-effort, short timeout
352
+ await this.#sendBatch([exitEvent], accessToken).catch(() => {});
353
+ }
354
+
355
+ get sessionId() {
356
+ return this.#sessionId;
357
+ }
358
+ get enabled() {
359
+ return this.#enabled && !this.#disabled;
360
+ }
361
+ }
362
+
363
+ const telemetryEmitter = new TelemetryEmitter();
364
+ const SESSION_START_TIME = Date.now();
365
+ /** @type {{ token: string }} Mutable ref to latest live access token for exit telemetry */
366
+ const liveTokenRef = { token: "" };
367
+
368
+ // Best-effort exit telemetry
369
+ process.on("beforeExit", () => {
370
+ const duration = Date.now() - SESSION_START_TIME;
371
+ telemetryEmitter.sendExitEvent(liveTokenRef.token, duration).catch(() => {});
372
+ });
373
+
102
374
  // ---------------------------------------------------------------------------
103
375
  // Request building helpers (extracted from original fetch interceptor)
104
376
  // ---------------------------------------------------------------------------
105
377
 
106
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.79";
378
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.80";
107
379
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
380
+
381
+ // The @anthropic-ai/sdk version bundled with Claude Code v2.1.80.
382
+ // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
383
+ const ANTHROPIC_SDK_VERSION = "0.52.0";
384
+
385
+ // Map of CLI version → bundled SDK version (update when CLI version changes)
386
+ const CLI_TO_SDK_VERSION = new Map([
387
+ ["2.1.80", "0.52.0"],
388
+ ["2.1.79", "0.51.0"],
389
+ ]);
390
+
391
+ /**
392
+ * Get the SDK version corresponding to a CLI version.
393
+ * Falls back to ANTHROPIC_SDK_VERSION constant.
394
+ * @param {string} cliVersion
395
+ * @returns {string}
396
+ */
397
+ function getSdkVersion(cliVersion) {
398
+ return CLI_TO_SDK_VERSION.get(cliVersion) ?? ANTHROPIC_SDK_VERSION;
399
+ }
108
400
  const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
109
401
  const EFFORT_BETA_FLAG = "effort-2025-11-24";
110
- const REDACT_THINKING_BETA_FLAG = "redact-thinking-2026-02-12";
111
402
  const ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
112
403
  const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
113
404
  const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
@@ -160,6 +451,7 @@ const STAINLESS_HELPER_KEYS = [
160
451
  ];
161
452
  const USER_ID_STORAGE_FILE = "anthropic-signature-user-id";
162
453
  const DEBUG_SYSTEM_PROMPT_ENV = "OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT";
454
+ const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
163
455
  const COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT = [
164
456
  "You are a title generator. You output ONLY a thread title. Nothing else.",
165
457
  "",
@@ -229,6 +521,31 @@ function buildUserAgent(claudeCliVersion) {
229
521
  return `claude-cli/${claudeCliVersion} (external, ${getClaudeEntrypoint()}${sdkSuffix}${appSuffix})`;
230
522
  }
231
523
 
524
+ /**
525
+ * Build the minimal User-Agent for OAuth/token endpoints.
526
+ * Claude Code sends "claude-code/{version}" on token exchange/refresh.
527
+ * @param {string} version
528
+ * @returns {string}
529
+ */
530
+ function buildMinimalUserAgent(version) {
531
+ return `claude-code/${version}`;
532
+ }
533
+
534
+ /**
535
+ * Build the extended User-Agent for API calls.
536
+ * Claude Code sends "claude-cli/{version} (external, {entrypoint})".
537
+ * @param {string} version
538
+ * @returns {string}
539
+ */
540
+ function buildExtendedUserAgent(version) {
541
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
542
+ const sdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` : "";
543
+ const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
544
+ ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`
545
+ : "";
546
+ return `claude-cli/${version} (external, ${entrypoint}${sdkVersion}${clientApp})`;
547
+ }
548
+
232
549
  /**
233
550
  * @returns {Record<string, string>}
234
551
  */
@@ -253,25 +570,25 @@ function parseAnthropicCustomHeaders() {
253
570
  }
254
571
 
255
572
  /**
573
+ * Returns the persistent device ID (64-char hex string).
574
+ * Migrates legacy UUID-format values to the new 64-hex format automatically.
256
575
  * @returns {string}
257
576
  */
258
- function getOrCreateSignatureUserId() {
259
- const envUserId = process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
260
- if (envUserId) return envUserId;
261
-
577
+ function getOrCreateDeviceId() {
262
578
  const configDir = getConfigDir();
263
579
  const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
264
580
 
265
581
  try {
266
582
  if (existsSync(userIdPath)) {
267
583
  const existing = readFileSync(userIdPath, "utf-8").trim();
268
- if (existing) return existing;
584
+ // Accept only valid 64-char lowercase hex; UUID (36 chars with dashes) triggers migration.
585
+ if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
269
586
  }
270
587
  } catch {
271
588
  // fall through and generate a new id
272
589
  }
273
590
 
274
- const generated = randomUUID();
591
+ const generated = randomBytes(32).toString("hex");
275
592
  try {
276
593
  mkdirSync(configDir, { recursive: true });
277
594
  writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
@@ -340,6 +657,26 @@ function isOpus46Model(model) {
340
657
  return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
341
658
  }
342
659
 
660
+ /**
661
+ * Detects claude-sonnet-4.6 / claude-sonnet-4-6 model IDs.
662
+ * @param {string} model
663
+ * @returns {boolean}
664
+ */
665
+ function isSonnet46Model(model) {
666
+ if (!model) return false;
667
+ return /claude-sonnet-4[._-]6|sonnet[._-]4[._-]6/i.test(model);
668
+ }
669
+
670
+ /**
671
+ * Detects models that support adaptive thinking ({type: "adaptive"}).
672
+ * Currently: Opus 4.6 and Sonnet 4.6.
673
+ * @param {string} model
674
+ * @returns {boolean}
675
+ */
676
+ function isAdaptiveThinkingModel(model) {
677
+ return isOpus46Model(model) || isSonnet46Model(model);
678
+ }
679
+
343
680
  /**
344
681
  * @param {string} model
345
682
  * @returns {boolean}
@@ -452,31 +789,51 @@ function getAccountIdentifier(account) {
452
789
  * @returns {{user_id: string}}
453
790
  */
454
791
  function buildRequestMetadata(input) {
455
- /** @type {Record<string, any>} */
456
- const metadata = {
457
- user_id: `user_${input.persistentUserId}_account_${input.accountId}_session_${input.sessionId}`,
458
- };
792
+ // Backward-compat override: raw user_id passed through without JSON-encoding.
793
+ const envUserId = process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
794
+ if (envUserId) return { user_id: envUserId };
459
795
 
460
- // v2.1.51: pass through SDK caller metadata when available
461
- const orgUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID?.trim();
462
- if (orgUuid) metadata.organization_uuid = orgUuid;
463
- const userEmail = process.env.CLAUDE_CODE_USER_EMAIL?.trim();
464
- if (userEmail) metadata.user_email = userEmail;
796
+ const extraMetadataEnv = process.env.CLAUDE_CODE_EXTRA_METADATA?.trim();
797
+ let extraMetadata = {};
798
+ if (extraMetadataEnv) {
799
+ try {
800
+ const parsed = JSON.parse(extraMetadataEnv);
801
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
802
+ extraMetadata = parsed;
803
+ }
804
+ } catch {
805
+ /* ignore */
806
+ }
807
+ }
465
808
 
466
- return metadata;
809
+ return {
810
+ user_id: JSON.stringify({
811
+ ...extraMetadata,
812
+ device_id: input.persistentUserId,
813
+ account_uuid: input.accountId,
814
+ session_id: input.sessionId,
815
+ }),
816
+ };
467
817
  }
468
818
 
469
819
  /**
470
- * @param {string} claudeCliVersion
471
- * @param {any[]} messages
820
+ * Build the billing header block for Claude Code system prompt injection.
821
+ * Claude Code v2.1.80: cch=00000 (hardcoded), cc_version includes model ID.
822
+ *
823
+ * @param {string} version - CLI version (e.g., "2.1.80")
824
+ * @param {string} [modelId] - Model ID to append (e.g., "claude-opus-4-6")
472
825
  * @returns {string}
473
826
  */
474
- function buildAnthropicBillingHeader(claudeCliVersion) {
827
+ function buildAnthropicBillingHeader(version, modelId) {
475
828
  if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
476
-
477
- const cch = randomBytes(3).toString("hex").slice(0, 5);
478
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
479
- return `x-anthropic-billing-header: cc_version=${claudeCliVersion}; cc_entrypoint=${entrypoint}; cch=${cch};`;
829
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "unknown";
830
+ const ccVersion = modelId ? `${version}.${modelId}` : version;
831
+ let header = `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=${entrypoint}; cch=00000;`;
832
+ const workload = process.env.CLAUDE_CODE_WORKLOAD;
833
+ if (workload) {
834
+ header = header.replace(/;$/, ` cc_workload=${workload};`);
835
+ }
836
+ return header;
480
837
  }
481
838
 
482
839
  /**
@@ -618,9 +975,22 @@ function normalizeSystemTextBlocks(system) {
618
975
  return output;
619
976
  }
620
977
 
978
+ /**
979
+ * Return the cache_control object appropriate for the given cache policy.
980
+ * @param {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | undefined} cachePolicy
981
+ * @returns {{type: string, ttl?: string}}
982
+ */
983
+ function getCacheControlForPolicy(cachePolicy) {
984
+ if (!cachePolicy) return { type: "ephemeral" };
985
+ if (cachePolicy.ttl === "off" || cachePolicy.ttl_supported === false) {
986
+ return { type: "ephemeral" };
987
+ }
988
+ return { type: "ephemeral", ttl: cachePolicy.ttl };
989
+ }
990
+
621
991
  /**
622
992
  * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
623
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off'}} signature
993
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
624
994
  * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
625
995
  */
626
996
  function buildSystemPromptBlocks(system, signature) {
@@ -646,13 +1016,73 @@ function buildSystemPromptBlocks(system, signature) {
646
1016
  );
647
1017
 
648
1018
  const blocks = [];
649
- const billingHeader = buildAnthropicBillingHeader(signature.claudeCliVersion);
1019
+ const billingHeader = buildAnthropicBillingHeader(signature.claudeCliVersion, signature.modelId);
650
1020
  if (billingHeader) {
1021
+ // Billing header: no cache_control (null scope — never cached)
651
1022
  blocks.push({ type: "text", text: billingHeader });
652
1023
  }
653
1024
 
654
- blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: { type: "ephemeral" } });
655
- blocks.push(...filtered);
1025
+ // Compute cache_control once — used for identity block AND filtered blocks.
1026
+ // TTL must be non-decreasing across tools → system → messages, so all cached
1027
+ // system blocks must share the same TTL to avoid "1h after 5m" API rejection.
1028
+ const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
1029
+ const cacheControl =
1030
+ effectiveCachePolicy.ttl !== "off" && effectiveCachePolicy.ttl_supported !== false
1031
+ ? { type: "ephemeral", ttl: effectiveCachePolicy.ttl }
1032
+ : { type: "ephemeral" };
1033
+
1034
+ // Identity block: org-scope cached per RE doc §14.1, §15.17
1035
+ // Uses same TTL as other cached blocks to satisfy API ordering constraint.
1036
+ blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: cacheControl });
1037
+
1038
+ // Filtered blocks: keep as-is, with optional static/dynamic boundary marker
1039
+ if (filtered.length > 0) {
1040
+ const useBoundary =
1041
+ signature.cachePolicy?.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
1042
+
1043
+ if (useBoundary) {
1044
+ // Heuristic: treat first half as "static" (tool defs, instructions)
1045
+ // and second half as "dynamic" (env info, memory, etc.)
1046
+ // Find a split point: look for blocks containing environment/date/CWD info
1047
+ // as the boundary between static and dynamic.
1048
+ const splitIndex = filtered.findIndex((block) => {
1049
+ const text = block.text.toLowerCase();
1050
+ return (
1051
+ text.includes("working directory") ||
1052
+ text.includes("today's date") ||
1053
+ text.includes("current date") ||
1054
+ text.includes("environment") ||
1055
+ text.includes("platform:")
1056
+ );
1057
+ });
1058
+
1059
+ const effectiveSplit = splitIndex > 0 ? splitIndex : Math.ceil(filtered.length / 2);
1060
+
1061
+ // Static blocks (before boundary) get cache_control
1062
+ for (let i = 0; i < effectiveSplit; i++) {
1063
+ blocks.push({ ...filtered[i], cache_control: cacheControl });
1064
+ }
1065
+
1066
+ // Boundary marker
1067
+ blocks.push({ type: "text", text: SYSTEM_PROMPT_DYNAMIC_BOUNDARY });
1068
+
1069
+ // Dynamic blocks (after boundary) get NO cache_control
1070
+ for (let i = effectiveSplit; i < filtered.length; i++) {
1071
+ const { cache_control: _cc, ...rest } = filtered[i];
1072
+ blocks.push(rest);
1073
+ }
1074
+ } else {
1075
+ // Original behavior: only last block gets cache_control.
1076
+ // Strip any upstream cache_control from intermediate blocks to prevent
1077
+ // TTL ordering violations (e.g., upstream 5m followed by our 1h).
1078
+ for (let i = 0; i < filtered.length - 1; i++) {
1079
+ const { cache_control: _cc, ...rest } = filtered[i];
1080
+ blocks.push(rest);
1081
+ }
1082
+ const lastFiltered = filtered[filtered.length - 1];
1083
+ blocks.push({ ...lastFiltered, cache_control: cacheControl });
1084
+ }
1085
+ }
656
1086
 
657
1087
  return blocks;
658
1088
  }
@@ -704,94 +1134,96 @@ function buildAnthropicBetaHeader(
704
1134
  const haiku = isHaikuModel(model);
705
1135
  const isRoundRobin = strategy === "round-robin";
706
1136
 
1137
+ // === ALWAYS-ON BETAS (Claude Code v2.1.80 base set) ===
1138
+ // These are ALWAYS included regardless of env vars or feature flags.
707
1139
  if (!haiku) {
708
- betas.push(CLAUDE_CODE_BETA_FLAG);
1140
+ betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
709
1141
  }
1142
+ betas.push(ADVANCED_TOOL_USE_BETA_FLAG); // "advanced-tool-use-2025-11-20"
1143
+ betas.push(FAST_MODE_BETA_FLAG); // "fast-mode-2026-02-01"
1144
+ betas.push(EFFORT_BETA_FLAG); // "effort-2025-11-24"
710
1145
 
711
- // Files API beta is endpoint/content-scoped instead of globally applied.
712
- if ((isFilesEndpoint || hasFileReferences) && !disableExperimentalBetas) {
713
- betas.push("files-api-2025-04-14");
714
- }
715
-
716
- // NOTE: redact-thinking-2026-02-12 is in upstream 2.1.79+ base profile but
717
- // intentionally NOT auto-included here — OpenCode users benefit from seeing
718
- // thinking blocks. Available via /anthropic betas add redact-thinking-2026-02-12.
719
-
720
- // Advanced tool use improvements — in upstream 2.1.79+ base profile.
721
- if (!disableExperimentalBetas) {
722
- betas.push(ADVANCED_TOOL_USE_BETA_FLAG);
723
- }
724
-
725
- // Fast mode — in upstream 2.1.79+ base profile.
726
- if (!disableExperimentalBetas) {
727
- betas.push(FAST_MODE_BETA_FLAG);
728
- }
729
-
730
- if (isOpus46Model(model)) {
731
- // Opus 4.6 uses effort-based thinking controls.
732
- betas.push(EFFORT_BETA_FLAG);
733
- } else if (
734
- !disableExperimentalBetas &&
735
- !isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) &&
736
- supportsThinking(model)
737
- ) {
1146
+ // Interleaved thinking — always-on unless explicitly disabled
1147
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING)) {
738
1148
  betas.push("interleaved-thinking-2025-05-14");
739
1149
  }
740
1150
 
741
- // context-1m-2025-08-07 is only supported for API key users; OAuth provider does not support it.
742
- // For OAuth (this plugin's only auth mode), compaction is gated by model.limit.input instead.
743
- if (!disableExperimentalBetas && hasOneMillionContext(model) && provider !== "anthropic") {
1151
+ // Context 1M — always-on for eligible models (Claude Code includes this for OAuth too)
1152
+ if (hasOneMillionContext(model)) {
744
1153
  betas.push("context-1m-2025-08-07");
745
1154
  }
746
1155
 
747
- if (
748
- !disableExperimentalBetas &&
749
- nonInteractive &&
750
- (isTruthyEnv(process.env.USE_API_CONTEXT_MANAGEMENT) || isTruthyEnv(process.env.TENGU_MARBLE_ANVIL))
751
- ) {
752
- betas.push("context-management-2025-06-27");
1156
+ // Prompt caching scope — always-on EXCEPT in round-robin (per-workspace state conflicts)
1157
+ if (!isRoundRobin) {
1158
+ betas.push("prompt-caching-scope-2026-01-05");
753
1159
  }
754
1160
 
755
- if (!disableExperimentalBetas && supportsStructuredOutputs(model) && isTruthyEnv(process.env.TENGU_TOOL_PEAR)) {
1161
+ // === CONDITIONAL BETAS (model/context-dependent) ===
1162
+
1163
+ // Context management — always-on in Claude Code (not env-gated)
1164
+ betas.push("context-management-2025-06-27");
1165
+
1166
+ // Structured outputs — always-on for capable models
1167
+ if (supportsStructuredOutputs(model)) {
756
1168
  betas.push("structured-outputs-2025-12-15");
757
1169
  }
758
1170
 
759
- if (!disableExperimentalBetas && nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
760
- betas.push("tool-examples-2025-10-29");
761
- }
1171
+ // Tool examples — always-on in Claude Code
1172
+ betas.push("tool-examples-2025-10-29");
762
1173
 
763
- if (!disableExperimentalBetas && (provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
1174
+ // Web search — for models that support it
1175
+ if (supportsWebSearch(model)) {
764
1176
  betas.push("web-search-2025-03-05");
765
1177
  }
766
1178
 
767
- // Prompt caching is per-workspace (since Feb 2026); round-robin across accounts
768
- // means zero cache hits and doubled token costs. Skip in round-robin.
769
- if (!disableExperimentalBetas && nonInteractive && !isRoundRobin) {
770
- betas.push("prompt-caching-scope-2026-01-05");
1179
+ // Tool search tool
1180
+ betas.push("tool-search-tool-2025-10-19");
1181
+
1182
+ // Files API — scoped to file endpoints/references
1183
+ if (isFilesEndpoint || hasFileReferences) {
1184
+ betas.push("files-api-2025-04-14");
771
1185
  }
772
1186
 
1187
+ // Token counting endpoint
773
1188
  if (isMessagesCountTokensPath) {
774
1189
  betas.push(TOKEN_COUNTING_BETA_FLAG);
775
1190
  }
776
1191
 
777
- if (process.env.ANTHROPIC_BETAS) {
778
- const envBetas = process.env.ANTHROPIC_BETAS.split(",")
779
- .map((b) => b.trim())
780
- .filter(Boolean);
781
- betas.push(...envBetas);
782
- }
1192
+ // === OPTIONAL BETAS (env-gated, intentionally NOT always-on) ===
783
1193
 
784
- if (Array.isArray(customBetas)) {
785
- betas.push(...customBetas.filter(Boolean));
786
- }
1194
+ // redact-thinking — NOT auto-included (users benefit from seeing thinking blocks)
1195
+ // Available via: /anthropic betas add redact-thinking-2026-02-12
1196
+
1197
+ // afk-mode — NOT auto-included (requires user opt-in)
1198
+ // Available via: /anthropic betas add afk-mode-2026-01-31
787
1199
 
1200
+ // Merge incoming betas from the original request
788
1201
  let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
1202
+
1203
+ // Add custom betas from config
1204
+ if (customBetas?.length) {
1205
+ for (const custom of customBetas) {
1206
+ const resolved = BETA_SHORTCUTS.get(custom) || custom;
1207
+ if (resolved && !mergedBetas.includes(resolved)) {
1208
+ mergedBetas.push(resolved);
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ // Filter out experimental betas only if explicitly disabled.
1214
+ // WARNING: The EXPERIMENTAL_BETA_FLAGS set overlaps with most always-on betas.
1215
+ // Enabling CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS effectively strips Claude Code
1216
+ // mimicry betas, leaving only oauth-2025-04-20, claude-code-20250219, and effort-*.
1217
+ // Use this escape hatch only for debugging or when betas cause API rejections.
789
1218
  if (disableExperimentalBetas) {
790
1219
  mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
791
1220
  }
1221
+
1222
+ // Remove betas unsupported by Bedrock
792
1223
  if (provider === "bedrock") {
793
- return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
1224
+ mergedBetas = mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta));
794
1225
  }
1226
+
795
1227
  return mergedBetas.join(",");
796
1228
  }
797
1229
 
@@ -820,38 +1252,35 @@ function isValidEffort(value) {
820
1252
 
821
1253
  /**
822
1254
  * Normalise the `thinking` block in the request body for the target model:
823
- * - Opus 4.6 (effort-based thinking): produces `{ type: "enabled", effort: <effort> }`
1255
+ * - Opus 4.6 / Sonnet 4.6 (adaptive thinking): produces `{ type: "adaptive" }`
824
1256
  * - Older models: passes the existing thinking block through unchanged.
825
1257
  *
826
- * Handles three incoming shapes:
827
- * 1. Already effort-based: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
828
- * 2. Legacy manual: `{ type: "enabled", budget_tokens: N }` → mapped to effort for Opus 4.6
829
- * 3. Absent / disabled: no transform
830
- *
831
1258
  * @param {any} thinking
832
1259
  * @param {string} model
833
1260
  * @returns {any}
834
1261
  */
835
1262
  function normalizeThinkingBlock(thinking, model) {
836
- if (!thinking || typeof thinking !== "object" || thinking.type !== "enabled") {
837
- return thinking;
838
- }
839
-
840
- if (!isOpus46Model(model)) {
841
- // Older models: pass through unchanged (may have budget_tokens)
1263
+ // If thinking is absent or not an object, pass through
1264
+ if (!thinking || typeof thinking !== "object") {
842
1265
  return thinking;
843
1266
  }
844
1267
 
845
- // Opus 4.6: use adaptive thinking with effort
846
- if (isValidEffort(thinking.effort)) {
847
- // Already in adaptive shape — just strip any legacy budget_tokens field
848
- const { budget_tokens: _dropped, ...rest } = thinking;
849
- return rest;
1268
+ // Adaptive thinking models always get {type: "adaptive"}
1269
+ // regardless of what format the incoming thinking block has
1270
+ if (isAdaptiveThinkingModel(model)) {
1271
+ // Check for env-var override to force budget_tokens fallback
1272
+ if (isTruthyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
1273
+ // Fallback: return as-is if already budget_tokens shape, otherwise default
1274
+ if (thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
1275
+ return thinking;
1276
+ }
1277
+ return { type: "enabled", budget_tokens: parseInt(process.env.MAX_THINKING_TOKENS, 10) || 16000 };
1278
+ }
1279
+ return { type: "adaptive" };
850
1280
  }
851
1281
 
852
- const effort = typeof thinking.budget_tokens === "number" ? budgetTokensToEffort(thinking.budget_tokens) : "medium"; // v2.1.68 default
853
-
854
- return { type: "enabled", effort };
1282
+ // Non-adaptive models: pass through unchanged
1283
+ return thinking;
855
1284
  }
856
1285
 
857
1286
  /**
@@ -860,7 +1289,7 @@ function normalizeThinkingBlock(thinking, model) {
860
1289
  * @returns {string}
861
1290
  */
862
1291
  function getStainlessOs(value) {
863
- if (value === "darwin") return "MacOS";
1292
+ if (value === "darwin") return "macOS";
864
1293
  if (value === "win32") return "Windows";
865
1294
  if (value === "linux") return "Linux";
866
1295
  return value;
@@ -962,23 +1391,36 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
962
1391
 
963
1392
  requestHeaders.set("authorization", `Bearer ${bearerToken}`);
964
1393
  requestHeaders.set("anthropic-beta", mergedBetas);
965
- requestHeaders.set("user-agent", buildUserAgent(signature.claudeCliVersion));
1394
+ requestHeaders.set("user-agent", buildExtendedUserAgent(signature.claudeCliVersion));
966
1395
  if (signature.enabled) {
967
1396
  requestHeaders.set("anthropic-version", "2023-06-01");
968
- requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
969
1397
  requestHeaders.set("x-app", "cli");
970
1398
  requestHeaders.set("x-stainless-arch", getStainlessArch(process.arch));
971
1399
  requestHeaders.set("x-stainless-lang", "js");
972
1400
  requestHeaders.set("x-stainless-os", getStainlessOs(process.platform));
973
- requestHeaders.set("x-stainless-package-version", signature.claudeCliVersion);
1401
+ requestHeaders.set("x-stainless-package-version", getSdkVersion(signature.claudeCliVersion));
974
1402
  requestHeaders.set("x-stainless-runtime", "node");
975
1403
  requestHeaders.set("x-stainless-runtime-version", process.version);
976
- requestHeaders.set("x-stainless-helper-method", "stream");
977
1404
  const incomingRetryCount = requestHeaders.get("x-stainless-retry-count");
978
1405
  requestHeaders.set(
979
1406
  "x-stainless-retry-count",
980
1407
  incomingRetryCount && !isFalsyEnv(incomingRetryCount) ? incomingRetryCount : "0",
981
1408
  );
1409
+ // x-stainless-timeout: sent only for non-streaming requests.
1410
+ // Claude Code sends 600 (10 minutes) as the default timeout in seconds.
1411
+ // We detect streaming from the request body.
1412
+ const isStreaming = (() => {
1413
+ try {
1414
+ if (!requestBody) return true;
1415
+ const parsed = JSON.parse(requestBody);
1416
+ return parsed.stream !== false;
1417
+ } catch {
1418
+ return true; // assume streaming if body can't be parsed
1419
+ }
1420
+ })();
1421
+ if (!isStreaming) {
1422
+ requestHeaders.set("x-stainless-timeout", "600");
1423
+ }
982
1424
  const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
983
1425
  if (stainlessHelpers) {
984
1426
  requestHeaders.set("x-stainless-helper", stainlessHelpers);
@@ -1010,25 +1452,54 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
1010
1452
  * Preserves behaviors E1-E7.
1011
1453
  *
1012
1454
  * @param {string | undefined} body
1013
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off'}} signature
1455
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
1014
1456
  * @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
1457
+ * @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
1015
1458
  * @returns {string | undefined}
1016
1459
  */
1017
- function transformRequestBody(body, signature, runtime) {
1460
+ function transformRequestBody(body, signature, runtime, betaHeader) {
1018
1461
  if (!body || typeof body !== "string") return body;
1019
1462
 
1020
1463
  const TOOL_PREFIX = "mcp_";
1021
1464
 
1022
1465
  try {
1023
1466
  const parsed = JSON.parse(body);
1467
+ // Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
1468
+ // doesn't forward custom HTTP headers. First-party API rejects "betas" in body
1469
+ // with "Extra inputs are not permitted" — betas are header-only for first-party.
1470
+ if (signature.enabled && betaHeader && signature.provider === "bedrock") {
1471
+ const betaArray = betaHeader
1472
+ .split(",")
1473
+ .map((b) => b.trim())
1474
+ .filter(Boolean)
1475
+ .filter((b) => b !== "oauth-2025-04-20");
1476
+ parsed.anthropic_beta = betaArray;
1477
+ }
1478
+ // Strip any incoming "betas" field — API rejects it as unknown
1024
1479
  if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
1025
1480
  delete parsed.betas;
1026
1481
  }
1027
- // Normalize thinking block for adaptive (Opus 4.6) vs manual (older models).
1482
+ // Normalize thinking block for adaptive (Opus 4.6 / Sonnet 4.6) vs manual (older models).
1028
1483
  if (Object.prototype.hasOwnProperty.call(parsed, "thinking")) {
1029
1484
  parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
1030
1485
  }
1031
1486
 
1487
+ // Claude Code temperature rule: when extended thinking is active (any type),
1488
+ // temperature must be omitted (undefined). Otherwise default to 1.
1489
+ const thinkingActive =
1490
+ parsed.thinking &&
1491
+ typeof parsed.thinking === "object" &&
1492
+ (parsed.thinking.type === "adaptive" || parsed.thinking.type === "enabled");
1493
+ if (thinkingActive) {
1494
+ // Anthropic API rejects temperature when thinking is enabled
1495
+ delete parsed.temperature;
1496
+ } else {
1497
+ // Claude Code always uses temperature: 1 for non-thinking requests (RE doc §5.2, never 0)
1498
+ parsed.temperature = 1;
1499
+ }
1500
+
1501
+ // Pass the model ID to system prompt builder for billing header
1502
+ signature.modelId = parsed.model || "";
1032
1503
  // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
1033
1504
  parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature);
1034
1505
 
@@ -1071,6 +1542,11 @@ function transformRequestBody(body, signature, runtime) {
1071
1542
  return msg;
1072
1543
  });
1073
1544
  }
1545
+ // Fast mode: inject speed parameter for supported models
1546
+ const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
1547
+ if (fastModeEnabled && parsed.model && (isOpus46Model(parsed.model) || isSonnet46Model(parsed.model))) {
1548
+ parsed.speed = "fast";
1549
+ }
1074
1550
  return JSON.stringify(parsed);
1075
1551
  } catch {
1076
1552
  // ignore parse errors
@@ -1539,7 +2015,7 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
1539
2015
  return account.access;
1540
2016
  }
1541
2017
 
1542
- const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(10_000) });
2018
+ const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(15_000) });
1543
2019
 
1544
2020
  account.access = json.access_token;
1545
2021
  account.expires = Date.now() + json.expires_in * 1000;
@@ -1659,6 +2135,18 @@ export async function AnthropicAuthPlugin({ client }) {
1659
2135
  const shouldFetchClaudeCodeVersion =
1660
2136
  signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
1661
2137
 
2138
+ // Per-instance strategy state (moved from module-level for test isolation)
2139
+ const strategyState = {
2140
+ mode: "CONFIGURED", // "CONFIGURED" | "DEGRADED"
2141
+ rateLimitEvents: [], // timestamps of rate limit events in current window
2142
+ windowMs: 5 * 60 * 1000, // 5-minute sliding window
2143
+ thresholdCount: 3, // rate limits needed to trigger DEGRADED
2144
+ recoveryMs: 5 * 60 * 1000, // 5 minutes clean to recover
2145
+ lastRateLimitTime: 0,
2146
+ manualOverride: false, // user explicitly set strategy — disable auto-adaptation
2147
+ originalStrategy: null, // the user's configured strategy before DEGRADED override
2148
+ };
2149
+
1662
2150
  /** @type {AccountManager | null} */
1663
2151
  let accountManager = null;
1664
2152
 
@@ -1786,10 +2274,11 @@ export async function AnthropicAuthPlugin({ client }) {
1786
2274
  */
1787
2275
  async function startSlashOAuth(sessionID, mode, targetIndex) {
1788
2276
  pruneExpiredPendingOAuth();
1789
- const { url, verifier } = await authorize("max");
2277
+ const { url, verifier, state } = await authorize("max");
1790
2278
  pendingSlashOAuth.set(sessionID, {
1791
2279
  mode,
1792
2280
  verifier,
2281
+ state,
1793
2282
  targetIndex,
1794
2283
  createdAt: Date.now(),
1795
2284
  });
@@ -1837,6 +2326,17 @@ export async function AnthropicAuthPlugin({ client }) {
1837
2326
  };
1838
2327
  }
1839
2328
 
2329
+ // Validate CSRF state parameter (RFC 6749 §10.12)
2330
+ const codeParts = code.split("#");
2331
+ const returnedState = codeParts[1];
2332
+ if (pending.state && returnedState && returnedState !== pending.state) {
2333
+ pendingSlashOAuth.delete(sessionID);
2334
+ return {
2335
+ ok: false,
2336
+ message: "OAuth state mismatch — possible CSRF attack. Please start a new login flow.",
2337
+ };
2338
+ }
2339
+
1840
2340
  const credentials = await exchange(code, pending.verifier);
1841
2341
  if (credentials.type === "failed") {
1842
2342
  return {
@@ -2021,6 +2521,7 @@ export async function AnthropicAuthPlugin({ client }) {
2021
2521
  "▣ Anthropic Config",
2022
2522
  "",
2023
2523
  `strategy: ${fresh.account_selection_strategy}`,
2524
+ `strategy-state: ${strategyState.mode}${strategyState.manualOverride ? " (manual override)" : ""}`,
2024
2525
  `emulation: ${fresh.signature_emulation.enabled ? "on" : "off"}`,
2025
2526
  `compaction: ${fresh.signature_emulation.prompt_compaction}`,
2026
2527
  `1m-context: ${fresh.override_model_limits.enabled ? "on" : "off"}`,
@@ -2028,11 +2529,45 @@ export async function AnthropicAuthPlugin({ client }) {
2028
2529
  `debug: ${fresh.debug ? "on" : "off"}`,
2029
2530
  `quiet: ${fresh.toasts.quiet ? "on" : "off"}`,
2030
2531
  `custom_betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
2532
+ `cache-boundary: ${fresh.cache_policy?.boundary_marker ? "on" : "off"}`,
2533
+ `cache-ttl: ${fresh.cache_policy?.ttl ?? "1h"}${fresh.cache_policy?.ttl_supported === false ? " (auto-disabled)" : ""}`,
2534
+ `fast-mode: ${fresh.fast_mode ? "on" : "off"}`,
2535
+ `telemetry-emulation: ${fresh.telemetry?.emulate_minimal ? "on (silent observer)" : "off"}`,
2031
2536
  ];
2032
2537
  await sendCommandMessage(input.sessionID, lines.join("\n"));
2033
2538
  return;
2034
2539
  }
2035
2540
 
2541
+ // /anthropic stats — show session statistics
2542
+ if (primary === "stats") {
2543
+ const avgRate = getAverageCacheHitRate();
2544
+ const totalTokens =
2545
+ sessionMetrics.totalInput +
2546
+ sessionMetrics.totalOutput +
2547
+ sessionMetrics.totalCacheRead +
2548
+ sessionMetrics.totalCacheWrite;
2549
+ const lines = [
2550
+ "▣ Anthropic Session Stats",
2551
+ "",
2552
+ `Turns: ${sessionMetrics.turns}`,
2553
+ `Total tokens: ${totalTokens.toLocaleString()}`,
2554
+ ` Input: ${sessionMetrics.totalInput.toLocaleString()}`,
2555
+ ` Output: ${sessionMetrics.totalOutput.toLocaleString()}`,
2556
+ ` Cache read: ${sessionMetrics.totalCacheRead.toLocaleString()}`,
2557
+ ` Cache write: ${sessionMetrics.totalCacheWrite.toLocaleString()}`,
2558
+ `Cache efficiency: ${(avgRate * 100).toFixed(1)}% (last ${sessionMetrics.recentCacheRates.length} turns)`,
2559
+ `Session cost: $${sessionMetrics.sessionCostUsd.toFixed(4)}`,
2560
+ ];
2561
+ const maxBudget = parseFloat(process.env.OPENCODE_ANTHROPIC_MAX_BUDGET_USD || "0");
2562
+ if (maxBudget > 0) {
2563
+ lines.push(
2564
+ `Budget: $${sessionMetrics.sessionCostUsd.toFixed(2)} / $${maxBudget.toFixed(2)} (${((sessionMetrics.sessionCostUsd / maxBudget) * 100).toFixed(0)}%)`,
2565
+ );
2566
+ }
2567
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2568
+ return;
2569
+ }
2570
+
2036
2571
  // /anthropic set <key> <value> — toggle features at runtime
2037
2572
  if (primary === "set") {
2038
2573
  const key = (args[1] || "").toLowerCase();
@@ -2051,8 +2586,40 @@ export async function AnthropicAuthPlugin({ client }) {
2051
2586
  quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
2052
2587
  strategy: () => {
2053
2588
  const valid = ["sticky", "round-robin", "hybrid"];
2054
- if (valid.includes(value)) saveConfig({ account_selection_strategy: value });
2055
- else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
2589
+ if (valid.includes(value)) {
2590
+ saveConfig({ account_selection_strategy: value });
2591
+ strategyState.manualOverride = true;
2592
+ strategyState.mode = "CONFIGURED";
2593
+ } else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
2594
+ },
2595
+ boundary: () =>
2596
+ saveConfig({ cache_policy: { boundary_marker: value === "on" || value === "1" || value === "true" } }),
2597
+ "cache-ttl": () => {
2598
+ const valid = ["1h", "5m", "off"];
2599
+ if (valid.includes(value)) saveConfig({ cache_policy: { ttl: value } });
2600
+ else throw new Error(`Invalid TTL. Valid: ${valid.join(", ")}`);
2601
+ },
2602
+ fast: () => {
2603
+ const enabled = value === "on" || value === "true" || value === "1";
2604
+ saveConfig({ fast_mode: enabled });
2605
+ config.fast_mode = enabled;
2606
+ },
2607
+ "fast-mode": () => {
2608
+ const enabled = value === "on" || value === "true" || value === "1";
2609
+ saveConfig({ fast_mode: enabled });
2610
+ config.fast_mode = enabled;
2611
+ },
2612
+ telemetry: () => {
2613
+ const enabled = value === "on" || value === "true" || value === "1";
2614
+ saveConfig({ telemetry: { emulate_minimal: enabled } });
2615
+ config.telemetry = config.telemetry || {};
2616
+ config.telemetry.emulate_minimal = enabled;
2617
+ },
2618
+ "telemetry-emulation": () => {
2619
+ const enabled = value === "on" || value === "true" || value === "1";
2620
+ saveConfig({ telemetry: { emulate_minimal: enabled } });
2621
+ config.telemetry = config.telemetry || {};
2622
+ config.telemetry.emulate_minimal = enabled;
2056
2623
  },
2057
2624
  };
2058
2625
 
@@ -2579,9 +3146,52 @@ export async function AnthropicAuthPlugin({ client }) {
2579
3146
  console.error("[opencode-anthropic-auth]", ...args);
2580
3147
  }
2581
3148
 
3149
+ function recordRateLimitForStrategy() {
3150
+ const now = Date.now();
3151
+ strategyState.rateLimitEvents.push(now);
3152
+ strategyState.lastRateLimitTime = now;
3153
+
3154
+ // Prune events outside window
3155
+ const cutoff = now - strategyState.windowMs;
3156
+ strategyState.rateLimitEvents = strategyState.rateLimitEvents.filter((t) => t > cutoff);
3157
+
3158
+ // Check transition to DEGRADED
3159
+ if (strategyState.mode === "CONFIGURED" && !strategyState.manualOverride) {
3160
+ if (strategyState.rateLimitEvents.length >= strategyState.thresholdCount) {
3161
+ strategyState.originalStrategy = config.account_selection_strategy;
3162
+ strategyState.mode = "DEGRADED";
3163
+ debugLog("auto-strategy: transitioning to DEGRADED mode", {
3164
+ rateLimitsInWindow: strategyState.rateLimitEvents.length,
3165
+ });
3166
+ toast("Multiple rate limits detected, temporarily rotating accounts more aggressively", "warning", {
3167
+ debounceKey: "strategy-degraded",
3168
+ }).catch(() => {});
3169
+ }
3170
+ }
3171
+ }
3172
+
3173
+ function checkStrategyRecovery() {
3174
+ if (strategyState.mode !== "DEGRADED" || strategyState.manualOverride) return;
3175
+
3176
+ const now = Date.now();
3177
+ if (now - strategyState.lastRateLimitTime >= strategyState.recoveryMs) {
3178
+ strategyState.mode = "CONFIGURED";
3179
+ strategyState.rateLimitEvents = [];
3180
+ debugLog("auto-strategy: recovered to CONFIGURED mode");
3181
+ toast("Rate limit pressure relieved, restoring normal account selection", "info", {
3182
+ debounceKey: "strategy-recovered",
3183
+ }).catch(() => {});
3184
+ }
3185
+ }
3186
+
3187
+ function getEffectiveStrategy() {
3188
+ if (strategyState.mode === "DEGRADED") return "hybrid";
3189
+ return config.account_selection_strategy;
3190
+ }
3191
+
2582
3192
  let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
2583
3193
  const signatureSessionId = randomUUID();
2584
- const signatureUserId = getOrCreateSignatureUserId();
3194
+ const signatureUserId = getOrCreateDeviceId();
2585
3195
  if (shouldFetchClaudeCodeVersion) {
2586
3196
  fetchLatestClaudeCodeVersion()
2587
3197
  .then((version) => {
@@ -2862,6 +3472,19 @@ export async function AnthropicAuthPlugin({ client }) {
2862
3472
  }
2863
3473
  }
2864
3474
 
3475
+ // Initialize telemetry emitter
3476
+ const telemetryEnabled =
3477
+ config.telemetry?.emulate_minimal || isTruthyEnv(process.env.OPENCODE_ANTHROPIC_TELEMETRY_EMULATE);
3478
+ const firstAccount = accountManager.getEnabledAccounts()[0];
3479
+ telemetryEmitter.init({
3480
+ enabled: telemetryEnabled,
3481
+ deviceId: getOrCreateDeviceId(),
3482
+ cliVersion: claudeCliVersion,
3483
+ accountUuid: getAccountIdentifier(firstAccount),
3484
+ orgUuid: process.env.CLAUDE_CODE_ORGANIZATION_UUID || "",
3485
+ sessionId: signatureSessionId,
3486
+ });
3487
+
2865
3488
  return {
2866
3489
  apiKey: "",
2867
3490
  /**
@@ -2930,6 +3553,7 @@ export async function AnthropicAuthPlugin({ client }) {
2930
3553
  }
2931
3554
  }
2932
3555
 
3556
+ let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
2933
3557
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2934
3558
  // Select account — use pinned account on first attempt if available
2935
3559
  const account =
@@ -2963,7 +3587,8 @@ export async function AnthropicAuthPlugin({ client }) {
2963
3587
  // Determine access token
2964
3588
  let accessToken;
2965
3589
  // Per-account token refresh
2966
- if (!account.access || !account.expires || account.expires < Date.now()) {
3590
+ // Refresh 5 minutes before expiry to avoid mid-request token expiration (RE doc §1.10)
3591
+ if (!account.access || !account.expires || account.expires < Date.now() + 300_000) {
2967
3592
  const attemptedRefreshToken = account.refreshToken;
2968
3593
  try {
2969
3594
  accessToken = await refreshAccountTokenSingleFlight(account);
@@ -3046,21 +3671,45 @@ export async function AnthropicAuthPlugin({ client }) {
3046
3671
  accessToken = account.access;
3047
3672
  }
3048
3673
 
3674
+ // Store live token for exit telemetry
3675
+ if (accessToken) liveTokenRef.token = accessToken;
3676
+
3049
3677
  // Keep non-active accounts warm without blocking the request.
3050
3678
  maybeRefreshIdleAccounts(account);
3051
3679
 
3680
+ // Pre-compute the beta header so it can be injected into both the
3681
+ // request body (betas field) and the anthropic-beta header.
3682
+ const { model: _reqModel, hasFileReferences: _reqHasFileRefs } = parseRequestBodyMetadata(
3683
+ requestInit.body,
3684
+ );
3685
+ const _reqProvider = detectProvider(requestUrl);
3686
+ const computedBetaHeader = buildAnthropicBetaHeader(
3687
+ "",
3688
+ signatureEmulationEnabled,
3689
+ _reqModel,
3690
+ _reqProvider,
3691
+ config.custom_betas,
3692
+ getEffectiveStrategy(),
3693
+ requestUrl?.pathname,
3694
+ _reqHasFileRefs,
3695
+ );
3696
+
3052
3697
  const body = transformRequestBody(
3053
3698
  requestInit.body,
3054
3699
  {
3055
3700
  enabled: signatureEmulationEnabled,
3056
3701
  claudeCliVersion,
3057
3702
  promptCompactionMode,
3703
+ provider: _reqProvider,
3704
+ cachePolicy: config.cache_policy || { ttl: "1h", ttl_supported: true },
3705
+ fastMode: config.fast_mode || false,
3058
3706
  },
3059
3707
  {
3060
3708
  persistentUserId: signatureUserId,
3061
3709
  sessionId: signatureSessionId,
3062
3710
  accountId: getAccountIdentifier(account),
3063
3711
  },
3712
+ computedBetaHeader,
3064
3713
  );
3065
3714
  logTransformedSystemPrompt(body);
3066
3715
 
@@ -3069,7 +3718,7 @@ export async function AnthropicAuthPlugin({ client }) {
3069
3718
  enabled: signatureEmulationEnabled,
3070
3719
  claudeCliVersion,
3071
3720
  customBetas: config.custom_betas,
3072
- strategy: config.account_selection_strategy,
3721
+ strategy: getEffectiveStrategy(),
3073
3722
  });
3074
3723
  // Execute the request
3075
3724
  let response;
@@ -3096,6 +3745,56 @@ export async function AnthropicAuthPlugin({ client }) {
3096
3745
  throw fetchError;
3097
3746
  }
3098
3747
 
3748
+ // Proactive rate limit detection from response headers
3749
+ // Check all rate-limit subtypes: tokens, requests, input-tokens (RE doc §5.6)
3750
+ if (response.ok && account && accountManager) {
3751
+ const RATE_LIMIT_SUBTYPES = ["tokens", "requests", "input-tokens"];
3752
+ let maxUtilization = 0;
3753
+ let maxUtilizationSubtype = "";
3754
+ let anySurpassed = false;
3755
+ let surpassedResetAt = null;
3756
+
3757
+ for (const subtype of RATE_LIMIT_SUBTYPES) {
3758
+ const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-utilization`);
3759
+ const surpassed = response.headers.get(
3760
+ `anthropic-ratelimit-unified-${subtype}-surpassed-threshold`,
3761
+ );
3762
+ const resetAt = response.headers.get(`anthropic-ratelimit-unified-${subtype}-reset`);
3763
+
3764
+ if (utilizationStr) {
3765
+ const utilization = parseFloat(utilizationStr);
3766
+ if (!isNaN(utilization) && utilization > maxUtilization) {
3767
+ maxUtilization = utilization;
3768
+ maxUtilizationSubtype = subtype;
3769
+ }
3770
+ }
3771
+
3772
+ if (surpassed) {
3773
+ anySurpassed = true;
3774
+ surpassedResetAt = surpassedResetAt || resetAt;
3775
+ }
3776
+ }
3777
+
3778
+ if (maxUtilization > 0.8) {
3779
+ const penalty = Math.round((maxUtilization - 0.8) * 50); // 0-10 points
3780
+ accountManager.applyUtilizationPenalty(account, penalty);
3781
+ debugLog("high rate limit utilization", {
3782
+ accountIndex: account.index,
3783
+ subtype: maxUtilizationSubtype,
3784
+ utilization: (maxUtilization * 100).toFixed(1) + "%",
3785
+ penalty,
3786
+ });
3787
+ }
3788
+
3789
+ if (anySurpassed) {
3790
+ accountManager.applySurpassedThreshold(account, surpassedResetAt);
3791
+ debugLog("rate limit threshold surpassed", {
3792
+ accountIndex: account.index,
3793
+ resetAt: surpassedResetAt,
3794
+ });
3795
+ }
3796
+ }
3797
+
3099
3798
  // On error, check if it's account-specific or service-wide
3100
3799
  if (!response.ok && accountManager && account) {
3101
3800
  let errorBody = null;
@@ -3105,10 +3804,40 @@ export async function AnthropicAuthPlugin({ client }) {
3105
3804
  // Ignore read errors
3106
3805
  }
3107
3806
 
3807
+ // Auto-disable extended cache TTL if the API rejects it with a 400
3808
+ if (
3809
+ response.status === 400 &&
3810
+ errorBody &&
3811
+ (errorBody.includes("ttl") || errorBody.includes("cache_control"))
3812
+ ) {
3813
+ if (config.cache_policy && config.cache_policy.ttl_supported !== false) {
3814
+ config.cache_policy.ttl_supported = false;
3815
+ saveConfig({ cache_policy: { ttl_supported: false } });
3816
+ debugLog("cache TTL not supported by API, auto-disabled");
3817
+ }
3818
+ }
3819
+
3820
+ // Auto-disable fast mode if the API rejects speed parameter
3821
+ if (response.status === 400 && errorBody && errorBody.includes("speed")) {
3822
+ if (config.fast_mode) {
3823
+ config.fast_mode = false;
3824
+ saveConfig({ fast_mode: false });
3825
+ debugLog("fast mode not supported by API, auto-disabled");
3826
+ }
3827
+ }
3828
+
3829
+ // Check x-should-retry header first — server override
3830
+ const shouldRetry = parseShouldRetryHeader(response);
3831
+ if (shouldRetry === false) {
3832
+ // Server says DO NOT retry — return error directly
3833
+ debugLog("x-should-retry: false — not retrying", { status: response.status });
3834
+ return transformResponse(response);
3835
+ }
3836
+
3108
3837
  if (isAccountSpecificError(response.status, errorBody)) {
3109
3838
  // Account-specific: mark this account, try the next one
3110
3839
  const reason = parseRateLimitReason(response.status, errorBody);
3111
- const retryAfterMs = parseRetryAfterHeader(response);
3840
+ const retryAfterMs = parseRetryAfterMsHeader(response) || parseRetryAfterHeader(response);
3112
3841
  const authOrPermissionIssue = reason === "AUTH_FAILED";
3113
3842
 
3114
3843
  // Auth failures should force token refresh on next use.
@@ -3126,6 +3855,9 @@ export async function AnthropicAuthPlugin({ client }) {
3126
3855
 
3127
3856
  accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
3128
3857
 
3858
+ // Track for auto-strategy adaptation
3859
+ recordRateLimitForStrategy();
3860
+
3129
3861
  const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
3130
3862
  const total = accountManager.getAccountCount();
3131
3863
  if (total > 1) {
@@ -3138,8 +3870,39 @@ export async function AnthropicAuthPlugin({ client }) {
3138
3870
  continue; // Try next account immediately
3139
3871
  }
3140
3872
 
3141
- // Service-wide error (529, 503, 500, etc.) — return to caller,
3142
- // switching accounts won't help
3873
+ // Service-wide error (529, 503, 500, etc.)
3874
+ // x-should-retry: true forces a retry even for service-wide errors (RE doc §5.5)
3875
+ if (shouldRetry === true) {
3876
+ const retryDelay = parseRetryAfterMsHeader(response) || parseRetryAfterHeader(response) || 2000;
3877
+ debugLog("x-should-retry: true on service-wide error, sleeping before retry", {
3878
+ status: response.status,
3879
+ retryDelay,
3880
+ });
3881
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
3882
+ // Decrement attempt so this retry doesn't consume an account slot
3883
+ attempt--;
3884
+ continue;
3885
+ }
3886
+
3887
+ // 529 (overloaded) and 503 (service unavailable) — brief sleep-and-retry
3888
+ // per RE doc §5.5 (Stainless SDK retries 500+ codes up to 2 times)
3889
+ if ((response.status === 529 || response.status === 503) && serviceWideRetryCount < 2) {
3890
+ serviceWideRetryCount++;
3891
+ const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 8);
3892
+ const jitter = 1 - Math.random() * 0.25;
3893
+ const sleepMs = Math.round(baseDelay * jitter * 1000);
3894
+ debugLog("service-wide retryable error, sleeping before retry", {
3895
+ status: response.status,
3896
+ attempt: serviceWideRetryCount,
3897
+ sleepMs,
3898
+ });
3899
+ await new Promise((resolve) => setTimeout(resolve, sleepMs));
3900
+ // Decrement attempt so this retry doesn't consume an account slot
3901
+ attempt--;
3902
+ continue;
3903
+ }
3904
+
3905
+ // Non-retryable service-wide error — return to caller
3143
3906
  debugLog("service-wide response error, returning directly", {
3144
3907
  status: response.status,
3145
3908
  });
@@ -3150,6 +3913,12 @@ export async function AnthropicAuthPlugin({ client }) {
3150
3913
  if (account && accountManager) {
3151
3914
  if (response.ok) {
3152
3915
  accountManager.markSuccess(account);
3916
+ checkStrategyRecovery();
3917
+
3918
+ // Fire startup telemetry (once per session, after first success)
3919
+ if (telemetryEmitter.enabled && account?.access) {
3920
+ telemetryEmitter.sendStartupEvents(account.access).catch(() => {});
3921
+ }
3153
3922
  }
3154
3923
  }
3155
3924
 
@@ -3159,6 +3928,37 @@ export async function AnthropicAuthPlugin({ client }) {
3159
3928
  const usageCallback = shouldInspectStream
3160
3929
  ? (/** @type {UsageStats} */ usage) => {
3161
3930
  accountManager.recordUsage(account.index, usage);
3931
+ // Phase 4: session metrics
3932
+ updateSessionMetrics(usage, _reqModel);
3933
+ // Cache hit rate warning
3934
+ if (sessionMetrics.turns >= 3) {
3935
+ const avgRate = getAverageCacheHitRate();
3936
+ const threshold = config.cache_policy?.hit_rate_warning_threshold ?? 0.3;
3937
+ if (avgRate < threshold) {
3938
+ debugLog("low cache hit rate", {
3939
+ avgRate: (avgRate * 100).toFixed(1) + "%",
3940
+ turns: sessionMetrics.turns,
3941
+ });
3942
+ }
3943
+ }
3944
+ // Budget warning
3945
+ const maxBudget = parseFloat(process.env.OPENCODE_ANTHROPIC_MAX_BUDGET_USD || "0");
3946
+ if (maxBudget > 0) {
3947
+ const pct = sessionMetrics.sessionCostUsd / maxBudget;
3948
+ if (pct >= 1.0 && !isTruthyEnv(process.env.OPENCODE_ANTHROPIC_IGNORE_BUDGET)) {
3949
+ toast(
3950
+ `Session budget exceeded ($${sessionMetrics.sessionCostUsd.toFixed(2)} / $${maxBudget.toFixed(2)})`,
3951
+ "warning",
3952
+ { debounceKey: "budget" },
3953
+ ).catch(() => {});
3954
+ } else if (pct >= 0.8) {
3955
+ toast(
3956
+ `Session at ${(pct * 100).toFixed(0)}% of budget ($${sessionMetrics.sessionCostUsd.toFixed(2)} / $${maxBudget.toFixed(2)})`,
3957
+ "warning",
3958
+ { debounceKey: "budget" },
3959
+ ).catch(() => {});
3960
+ }
3961
+ }
3162
3962
  }
3163
3963
  : null;
3164
3964