opencode-anthropic-fix 0.0.24 → 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
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
- import { createHash, randomUUID } from "node:crypto";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
6
  import { AccountManager } from "./lib/accounts.mjs";
@@ -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,17 +105,303 @@ 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";
402
+ const ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
403
+ const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
110
404
  const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
111
- const BILLING_HASH_SALT = "59cf53e54c78";
112
- const BILLING_HASH_POSITIONS = [4, 7, 20];
113
405
  const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
114
406
  const KNOWN_IDENTITY_STRINGS = new Set([
115
407
  CLAUDE_CODE_IDENTITY_STRING,
@@ -159,6 +451,7 @@ const STAINLESS_HELPER_KEYS = [
159
451
  ];
160
452
  const USER_ID_STORAGE_FILE = "anthropic-signature-user-id";
161
453
  const DEBUG_SYSTEM_PROMPT_ENV = "OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT";
454
+ const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
162
455
  const COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT = [
163
456
  "You are a title generator. You output ONLY a thread title. Nothing else.",
164
457
  "",
@@ -228,6 +521,31 @@ function buildUserAgent(claudeCliVersion) {
228
521
  return `claude-cli/${claudeCliVersion} (external, ${getClaudeEntrypoint()}${sdkSuffix}${appSuffix})`;
229
522
  }
230
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
+
231
549
  /**
232
550
  * @returns {Record<string, string>}
233
551
  */
@@ -252,25 +570,25 @@ function parseAnthropicCustomHeaders() {
252
570
  }
253
571
 
254
572
  /**
573
+ * Returns the persistent device ID (64-char hex string).
574
+ * Migrates legacy UUID-format values to the new 64-hex format automatically.
255
575
  * @returns {string}
256
576
  */
257
- function getOrCreateSignatureUserId() {
258
- const envUserId = process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
259
- if (envUserId) return envUserId;
260
-
577
+ function getOrCreateDeviceId() {
261
578
  const configDir = getConfigDir();
262
579
  const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
263
580
 
264
581
  try {
265
582
  if (existsSync(userIdPath)) {
266
583
  const existing = readFileSync(userIdPath, "utf-8").trim();
267
- 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;
268
586
  }
269
587
  } catch {
270
588
  // fall through and generate a new id
271
589
  }
272
590
 
273
- const generated = randomUUID();
591
+ const generated = randomBytes(32).toString("hex");
274
592
  try {
275
593
  mkdirSync(configDir, { recursive: true });
276
594
  writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
@@ -339,6 +657,26 @@ function isOpus46Model(model) {
339
657
  return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
340
658
  }
341
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
+
342
680
  /**
343
681
  * @param {string} model
344
682
  * @returns {boolean}
@@ -429,39 +767,6 @@ function buildStainlessHelperHeader(tools, messages) {
429
767
  return Array.from(helpers).join(", ");
430
768
  }
431
769
 
432
- /**
433
- * @param {any} content
434
- * @returns {string}
435
- */
436
- function extractTextContent(content) {
437
- if (typeof content === "string") return content;
438
- if (!Array.isArray(content)) return "";
439
-
440
- const parts = [];
441
- for (const block of content) {
442
- if (!block || typeof block !== "object") continue;
443
- if (block.type === "text" && typeof block.text === "string") {
444
- parts.push(block.text);
445
- }
446
- }
447
- return parts.join("\n");
448
- }
449
-
450
- /**
451
- * @param {any[]} messages
452
- * @returns {string}
453
- */
454
- function getFirstUserText(messages) {
455
- for (const message of messages) {
456
- if (!message || typeof message !== "object") continue;
457
- if (message.role !== "user") continue;
458
-
459
- const text = extractTextContent(message.content);
460
- if (text) return text;
461
- }
462
- return "";
463
- }
464
-
465
770
  /**
466
771
  * @param {{id?: string, accountUuid?: string} | null | undefined} account
467
772
  * @returns {string}
@@ -484,37 +789,51 @@ function getAccountIdentifier(account) {
484
789
  * @returns {{user_id: string}}
485
790
  */
486
791
  function buildRequestMetadata(input) {
487
- /** @type {Record<string, any>} */
488
- const metadata = {
489
- user_id: `user_${input.persistentUserId}_account_${input.accountId}_session_${input.sessionId}`,
490
- };
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 };
491
795
 
492
- // v2.1.51: pass through SDK caller metadata when available
493
- const orgUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID?.trim();
494
- if (orgUuid) metadata.organization_uuid = orgUuid;
495
- const userEmail = process.env.CLAUDE_CODE_USER_EMAIL?.trim();
496
- 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
+ }
497
808
 
498
- 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
+ };
499
817
  }
500
818
 
501
819
  /**
502
- * @param {string} claudeCliVersion
503
- * @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")
504
825
  * @returns {string}
505
826
  */
506
- function buildAnthropicBillingHeader(claudeCliVersion, messages) {
827
+ function buildAnthropicBillingHeader(version, modelId) {
507
828
  if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
508
-
509
- const firstUserText = getFirstUserText(messages);
510
- const sampled = BILLING_HASH_POSITIONS.map((position) => firstUserText[position] || "0").join("");
511
- const hash = createHash("sha256")
512
- .update(`${BILLING_HASH_SALT}${sampled}${claudeCliVersion}`)
513
- .digest("hex")
514
- .slice(0, 3);
515
-
516
829
  const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "unknown";
517
- return `x-anthropic-billing-header: cc_version=${claudeCliVersion}.${hash}; cc_entrypoint=${entrypoint}; cch=00000;`;
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;
518
837
  }
519
838
 
520
839
  /**
@@ -656,13 +975,25 @@ function normalizeSystemTextBlocks(system) {
656
975
  return output;
657
976
  }
658
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
+
659
991
  /**
660
992
  * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
661
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off'}} signature
662
- * @param {any[]} messages
993
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
663
994
  * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
664
995
  */
665
- function buildSystemPromptBlocks(system, signature, messages) {
996
+ function buildSystemPromptBlocks(system, signature) {
666
997
  const titleGeneratorRequest = isTitleGeneratorSystemBlocks(system);
667
998
 
668
999
  let sanitized = system.map((item) => ({
@@ -685,13 +1016,73 @@ function buildSystemPromptBlocks(system, signature, messages) {
685
1016
  );
686
1017
 
687
1018
  const blocks = [];
688
- const billingHeader = buildAnthropicBillingHeader(signature.claudeCliVersion, messages);
1019
+ const billingHeader = buildAnthropicBillingHeader(signature.claudeCliVersion, signature.modelId);
689
1020
  if (billingHeader) {
1021
+ // Billing header: no cache_control (null scope — never cached)
690
1022
  blocks.push({ type: "text", text: billingHeader });
691
1023
  }
692
1024
 
693
- blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: { type: "ephemeral" } });
694
- 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
+ }
695
1086
 
696
1087
  return blocks;
697
1088
  }
@@ -743,80 +1134,96 @@ function buildAnthropicBetaHeader(
743
1134
  const haiku = isHaikuModel(model);
744
1135
  const isRoundRobin = strategy === "round-robin";
745
1136
 
1137
+ // === ALWAYS-ON BETAS (Claude Code v2.1.80 base set) ===
1138
+ // These are ALWAYS included regardless of env vars or feature flags.
746
1139
  if (!haiku) {
747
- betas.push(CLAUDE_CODE_BETA_FLAG);
1140
+ betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
748
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"
749
1145
 
750
- // Files API beta is endpoint/content-scoped instead of globally applied.
751
- if ((isFilesEndpoint || hasFileReferences) && !disableExperimentalBetas) {
752
- betas.push("files-api-2025-04-14");
753
- }
754
-
755
- if (isOpus46Model(model)) {
756
- // Opus 4.6 uses effort-based thinking controls.
757
- betas.push(EFFORT_BETA_FLAG);
758
- } else if (
759
- !disableExperimentalBetas &&
760
- !isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) &&
761
- supportsThinking(model)
762
- ) {
1146
+ // Interleaved thinking — always-on unless explicitly disabled
1147
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING)) {
763
1148
  betas.push("interleaved-thinking-2025-05-14");
764
1149
  }
765
1150
 
766
- // context-1m-2025-08-07 is only supported for API key users; OAuth provider does not support it.
767
- // For OAuth (this plugin's only auth mode), compaction is gated by model.limit.input instead.
768
- 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)) {
769
1153
  betas.push("context-1m-2025-08-07");
770
1154
  }
771
1155
 
772
- if (
773
- !disableExperimentalBetas &&
774
- nonInteractive &&
775
- (isTruthyEnv(process.env.USE_API_CONTEXT_MANAGEMENT) || isTruthyEnv(process.env.TENGU_MARBLE_ANVIL))
776
- ) {
777
- 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");
778
1159
  }
779
1160
 
780
- 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)) {
781
1168
  betas.push("structured-outputs-2025-12-15");
782
1169
  }
783
1170
 
784
- if (!disableExperimentalBetas && nonInteractive && isTruthyEnv(process.env.TENGU_SCARF_COFFEE)) {
785
- betas.push("tool-examples-2025-10-29");
786
- }
1171
+ // Tool examples — always-on in Claude Code
1172
+ betas.push("tool-examples-2025-10-29");
787
1173
 
788
- if (!disableExperimentalBetas && (provider === "vertex" || provider === "foundry") && supportsWebSearch(model)) {
1174
+ // Web search — for models that support it
1175
+ if (supportsWebSearch(model)) {
789
1176
  betas.push("web-search-2025-03-05");
790
1177
  }
791
1178
 
792
- // Prompt caching is per-workspace (since Feb 2026); round-robin across accounts
793
- // means zero cache hits and doubled token costs. Skip in round-robin.
794
- if (!disableExperimentalBetas && nonInteractive && !isRoundRobin) {
795
- 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");
796
1185
  }
797
1186
 
1187
+ // Token counting endpoint
798
1188
  if (isMessagesCountTokensPath) {
799
1189
  betas.push(TOKEN_COUNTING_BETA_FLAG);
800
1190
  }
801
1191
 
802
- if (process.env.ANTHROPIC_BETAS) {
803
- const envBetas = process.env.ANTHROPIC_BETAS.split(",")
804
- .map((b) => b.trim())
805
- .filter(Boolean);
806
- betas.push(...envBetas);
807
- }
1192
+ // === OPTIONAL BETAS (env-gated, intentionally NOT always-on) ===
808
1193
 
809
- if (Array.isArray(customBetas)) {
810
- betas.push(...customBetas.filter(Boolean));
811
- }
1194
+ // redact-thinking — NOT auto-included (users benefit from seeing thinking blocks)
1195
+ // Available via: /anthropic betas add redact-thinking-2026-02-12
812
1196
 
1197
+ // afk-mode — NOT auto-included (requires user opt-in)
1198
+ // Available via: /anthropic betas add afk-mode-2026-01-31
1199
+
1200
+ // Merge incoming betas from the original request
813
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.
814
1218
  if (disableExperimentalBetas) {
815
1219
  mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
816
1220
  }
1221
+
1222
+ // Remove betas unsupported by Bedrock
817
1223
  if (provider === "bedrock") {
818
- return mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta)).join(",");
1224
+ mergedBetas = mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta));
819
1225
  }
1226
+
820
1227
  return mergedBetas.join(",");
821
1228
  }
822
1229
 
@@ -845,38 +1252,35 @@ function isValidEffort(value) {
845
1252
 
846
1253
  /**
847
1254
  * Normalise the `thinking` block in the request body for the target model:
848
- * - Opus 4.6 (effort-based thinking): produces `{ type: "enabled", effort: <effort> }`
1255
+ * - Opus 4.6 / Sonnet 4.6 (adaptive thinking): produces `{ type: "adaptive" }`
849
1256
  * - Older models: passes the existing thinking block through unchanged.
850
1257
  *
851
- * Handles three incoming shapes:
852
- * 1. Already effort-based: `{ type: "enabled", effort: "..." }` → kept as-is for Opus 4.6
853
- * 2. Legacy manual: `{ type: "enabled", budget_tokens: N }` → mapped to effort for Opus 4.6
854
- * 3. Absent / disabled: no transform
855
- *
856
1258
  * @param {any} thinking
857
1259
  * @param {string} model
858
1260
  * @returns {any}
859
1261
  */
860
1262
  function normalizeThinkingBlock(thinking, model) {
861
- if (!thinking || typeof thinking !== "object" || thinking.type !== "enabled") {
862
- return thinking;
863
- }
864
-
865
- if (!isOpus46Model(model)) {
866
- // 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") {
867
1265
  return thinking;
868
1266
  }
869
1267
 
870
- // Opus 4.6: use adaptive thinking with effort
871
- if (isValidEffort(thinking.effort)) {
872
- // Already in adaptive shape — just strip any legacy budget_tokens field
873
- const { budget_tokens: _dropped, ...rest } = thinking;
874
- 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" };
875
1280
  }
876
1281
 
877
- const effort = typeof thinking.budget_tokens === "number" ? budgetTokensToEffort(thinking.budget_tokens) : "medium"; // v2.1.68 default
878
-
879
- return { type: "enabled", effort };
1282
+ // Non-adaptive models: pass through unchanged
1283
+ return thinking;
880
1284
  }
881
1285
 
882
1286
  /**
@@ -885,7 +1289,7 @@ function normalizeThinkingBlock(thinking, model) {
885
1289
  * @returns {string}
886
1290
  */
887
1291
  function getStainlessOs(value) {
888
- if (value === "darwin") return "MacOS";
1292
+ if (value === "darwin") return "macOS";
889
1293
  if (value === "win32") return "Windows";
890
1294
  if (value === "linux") return "Linux";
891
1295
  return value;
@@ -987,23 +1391,36 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
987
1391
 
988
1392
  requestHeaders.set("authorization", `Bearer ${bearerToken}`);
989
1393
  requestHeaders.set("anthropic-beta", mergedBetas);
990
- requestHeaders.set("user-agent", buildUserAgent(signature.claudeCliVersion));
1394
+ requestHeaders.set("user-agent", buildExtendedUserAgent(signature.claudeCliVersion));
991
1395
  if (signature.enabled) {
992
1396
  requestHeaders.set("anthropic-version", "2023-06-01");
993
- requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
994
1397
  requestHeaders.set("x-app", "cli");
995
1398
  requestHeaders.set("x-stainless-arch", getStainlessArch(process.arch));
996
1399
  requestHeaders.set("x-stainless-lang", "js");
997
1400
  requestHeaders.set("x-stainless-os", getStainlessOs(process.platform));
998
- requestHeaders.set("x-stainless-package-version", signature.claudeCliVersion);
1401
+ requestHeaders.set("x-stainless-package-version", getSdkVersion(signature.claudeCliVersion));
999
1402
  requestHeaders.set("x-stainless-runtime", "node");
1000
1403
  requestHeaders.set("x-stainless-runtime-version", process.version);
1001
- requestHeaders.set("x-stainless-helper-method", "stream");
1002
1404
  const incomingRetryCount = requestHeaders.get("x-stainless-retry-count");
1003
1405
  requestHeaders.set(
1004
1406
  "x-stainless-retry-count",
1005
1407
  incomingRetryCount && !isFalsyEnv(incomingRetryCount) ? incomingRetryCount : "0",
1006
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
+ }
1007
1424
  const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
1008
1425
  if (stainlessHelpers) {
1009
1426
  requestHeaders.set("x-stainless-helper", stainlessHelpers);
@@ -1035,29 +1452,56 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
1035
1452
  * Preserves behaviors E1-E7.
1036
1453
  *
1037
1454
  * @param {string | undefined} body
1038
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off'}} signature
1455
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
1039
1456
  * @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
1457
+ * @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
1040
1458
  * @returns {string | undefined}
1041
1459
  */
1042
- function transformRequestBody(body, signature, runtime) {
1460
+ function transformRequestBody(body, signature, runtime, betaHeader) {
1043
1461
  if (!body || typeof body !== "string") return body;
1044
1462
 
1045
1463
  const TOOL_PREFIX = "mcp_";
1046
1464
 
1047
1465
  try {
1048
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
1049
1479
  if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
1050
1480
  delete parsed.betas;
1051
1481
  }
1052
- const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
1053
-
1054
- // 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).
1055
1483
  if (Object.prototype.hasOwnProperty.call(parsed, "thinking")) {
1056
1484
  parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
1057
1485
  }
1058
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 || "";
1059
1503
  // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
1060
- parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature, messages);
1504
+ parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature);
1061
1505
 
1062
1506
  if (signature.enabled) {
1063
1507
  const currentMetadata =
@@ -1098,6 +1542,11 @@ function transformRequestBody(body, signature, runtime) {
1098
1542
  return msg;
1099
1543
  });
1100
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
+ }
1101
1550
  return JSON.stringify(parsed);
1102
1551
  } catch {
1103
1552
  // ignore parse errors
@@ -1566,7 +2015,7 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
1566
2015
  return account.access;
1567
2016
  }
1568
2017
 
1569
- const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(10_000) });
2018
+ const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(15_000) });
1570
2019
 
1571
2020
  account.access = json.access_token;
1572
2021
  account.expires = Date.now() + json.expires_in * 1000;
@@ -1686,6 +2135,18 @@ export async function AnthropicAuthPlugin({ client }) {
1686
2135
  const shouldFetchClaudeCodeVersion =
1687
2136
  signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
1688
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
+
1689
2150
  /** @type {AccountManager | null} */
1690
2151
  let accountManager = null;
1691
2152
 
@@ -1813,10 +2274,11 @@ export async function AnthropicAuthPlugin({ client }) {
1813
2274
  */
1814
2275
  async function startSlashOAuth(sessionID, mode, targetIndex) {
1815
2276
  pruneExpiredPendingOAuth();
1816
- const { url, verifier } = await authorize("max");
2277
+ const { url, verifier, state } = await authorize("max");
1817
2278
  pendingSlashOAuth.set(sessionID, {
1818
2279
  mode,
1819
2280
  verifier,
2281
+ state,
1820
2282
  targetIndex,
1821
2283
  createdAt: Date.now(),
1822
2284
  });
@@ -1864,6 +2326,17 @@ export async function AnthropicAuthPlugin({ client }) {
1864
2326
  };
1865
2327
  }
1866
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
+
1867
2340
  const credentials = await exchange(code, pending.verifier);
1868
2341
  if (credentials.type === "failed") {
1869
2342
  return {
@@ -2048,6 +2521,7 @@ export async function AnthropicAuthPlugin({ client }) {
2048
2521
  "▣ Anthropic Config",
2049
2522
  "",
2050
2523
  `strategy: ${fresh.account_selection_strategy}`,
2524
+ `strategy-state: ${strategyState.mode}${strategyState.manualOverride ? " (manual override)" : ""}`,
2051
2525
  `emulation: ${fresh.signature_emulation.enabled ? "on" : "off"}`,
2052
2526
  `compaction: ${fresh.signature_emulation.prompt_compaction}`,
2053
2527
  `1m-context: ${fresh.override_model_limits.enabled ? "on" : "off"}`,
@@ -2055,7 +2529,41 @@ export async function AnthropicAuthPlugin({ client }) {
2055
2529
  `debug: ${fresh.debug ? "on" : "off"}`,
2056
2530
  `quiet: ${fresh.toasts.quiet ? "on" : "off"}`,
2057
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"}`,
2536
+ ];
2537
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2538
+ return;
2539
+ }
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)}`,
2058
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
+ }
2059
2567
  await sendCommandMessage(input.sessionID, lines.join("\n"));
2060
2568
  return;
2061
2569
  }
@@ -2078,8 +2586,40 @@ export async function AnthropicAuthPlugin({ client }) {
2078
2586
  quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
2079
2587
  strategy: () => {
2080
2588
  const valid = ["sticky", "round-robin", "hybrid"];
2081
- if (valid.includes(value)) saveConfig({ account_selection_strategy: value });
2082
- 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;
2083
2623
  },
2084
2624
  };
2085
2625
 
@@ -2114,6 +2654,7 @@ export async function AnthropicAuthPlugin({ client }) {
2114
2654
  "",
2115
2655
  "Preset betas (auto-computed per model/provider):",
2116
2656
  " oauth-2025-04-20, claude-code-20250219,",
2657
+ " advanced-tool-use-2025-11-20, fast-mode-2026-02-01,",
2117
2658
  " interleaved-thinking-2025-05-14 (non-Opus 4.6) OR effort-2025-11-24 (Opus 4.6),",
2118
2659
  " files-api-2025-04-14 (only /v1/files and requests with file_id),",
2119
2660
  " token-counting-2024-11-01 (only /v1/messages/count_tokens),",
@@ -2130,8 +2671,8 @@ export async function AnthropicAuthPlugin({ client }) {
2130
2671
  " /anthropic betas add web-search-2025-03-05",
2131
2672
  " /anthropic betas add compact-2026-01-12",
2132
2673
  " /anthropic betas add mcp-servers-2025-12-04",
2674
+ " /anthropic betas add redact-thinking-2026-02-12",
2133
2675
  " /anthropic betas add 1m (shortcut for context-1m-2025-08-07)",
2134
- " /anthropic betas add fast (shortcut for fast-mode-2026-02-01)",
2135
2676
  "",
2136
2677
  "Remove: /anthropic betas remove <beta>",
2137
2678
  ];
@@ -2605,9 +3146,52 @@ export async function AnthropicAuthPlugin({ client }) {
2605
3146
  console.error("[opencode-anthropic-auth]", ...args);
2606
3147
  }
2607
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
+
2608
3192
  let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
2609
3193
  const signatureSessionId = randomUUID();
2610
- const signatureUserId = getOrCreateSignatureUserId();
3194
+ const signatureUserId = getOrCreateDeviceId();
2611
3195
  if (shouldFetchClaudeCodeVersion) {
2612
3196
  fetchLatestClaudeCodeVersion()
2613
3197
  .then((version) => {
@@ -2888,6 +3472,19 @@ export async function AnthropicAuthPlugin({ client }) {
2888
3472
  }
2889
3473
  }
2890
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
+
2891
3488
  return {
2892
3489
  apiKey: "",
2893
3490
  /**
@@ -2956,6 +3553,7 @@ export async function AnthropicAuthPlugin({ client }) {
2956
3553
  }
2957
3554
  }
2958
3555
 
3556
+ let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
2959
3557
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2960
3558
  // Select account — use pinned account on first attempt if available
2961
3559
  const account =
@@ -2989,7 +3587,8 @@ export async function AnthropicAuthPlugin({ client }) {
2989
3587
  // Determine access token
2990
3588
  let accessToken;
2991
3589
  // Per-account token refresh
2992
- 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) {
2993
3592
  const attemptedRefreshToken = account.refreshToken;
2994
3593
  try {
2995
3594
  accessToken = await refreshAccountTokenSingleFlight(account);
@@ -3072,21 +3671,45 @@ export async function AnthropicAuthPlugin({ client }) {
3072
3671
  accessToken = account.access;
3073
3672
  }
3074
3673
 
3674
+ // Store live token for exit telemetry
3675
+ if (accessToken) liveTokenRef.token = accessToken;
3676
+
3075
3677
  // Keep non-active accounts warm without blocking the request.
3076
3678
  maybeRefreshIdleAccounts(account);
3077
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
+
3078
3697
  const body = transformRequestBody(
3079
3698
  requestInit.body,
3080
3699
  {
3081
3700
  enabled: signatureEmulationEnabled,
3082
3701
  claudeCliVersion,
3083
3702
  promptCompactionMode,
3703
+ provider: _reqProvider,
3704
+ cachePolicy: config.cache_policy || { ttl: "1h", ttl_supported: true },
3705
+ fastMode: config.fast_mode || false,
3084
3706
  },
3085
3707
  {
3086
3708
  persistentUserId: signatureUserId,
3087
3709
  sessionId: signatureSessionId,
3088
3710
  accountId: getAccountIdentifier(account),
3089
3711
  },
3712
+ computedBetaHeader,
3090
3713
  );
3091
3714
  logTransformedSystemPrompt(body);
3092
3715
 
@@ -3095,7 +3718,7 @@ export async function AnthropicAuthPlugin({ client }) {
3095
3718
  enabled: signatureEmulationEnabled,
3096
3719
  claudeCliVersion,
3097
3720
  customBetas: config.custom_betas,
3098
- strategy: config.account_selection_strategy,
3721
+ strategy: getEffectiveStrategy(),
3099
3722
  });
3100
3723
  // Execute the request
3101
3724
  let response;
@@ -3122,6 +3745,56 @@ export async function AnthropicAuthPlugin({ client }) {
3122
3745
  throw fetchError;
3123
3746
  }
3124
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
+
3125
3798
  // On error, check if it's account-specific or service-wide
3126
3799
  if (!response.ok && accountManager && account) {
3127
3800
  let errorBody = null;
@@ -3131,10 +3804,40 @@ export async function AnthropicAuthPlugin({ client }) {
3131
3804
  // Ignore read errors
3132
3805
  }
3133
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
+
3134
3837
  if (isAccountSpecificError(response.status, errorBody)) {
3135
3838
  // Account-specific: mark this account, try the next one
3136
3839
  const reason = parseRateLimitReason(response.status, errorBody);
3137
- const retryAfterMs = parseRetryAfterHeader(response);
3840
+ const retryAfterMs = parseRetryAfterMsHeader(response) || parseRetryAfterHeader(response);
3138
3841
  const authOrPermissionIssue = reason === "AUTH_FAILED";
3139
3842
 
3140
3843
  // Auth failures should force token refresh on next use.
@@ -3152,6 +3855,9 @@ export async function AnthropicAuthPlugin({ client }) {
3152
3855
 
3153
3856
  accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
3154
3857
 
3858
+ // Track for auto-strategy adaptation
3859
+ recordRateLimitForStrategy();
3860
+
3155
3861
  const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
3156
3862
  const total = accountManager.getAccountCount();
3157
3863
  if (total > 1) {
@@ -3164,8 +3870,39 @@ export async function AnthropicAuthPlugin({ client }) {
3164
3870
  continue; // Try next account immediately
3165
3871
  }
3166
3872
 
3167
- // Service-wide error (529, 503, 500, etc.) — return to caller,
3168
- // 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
3169
3906
  debugLog("service-wide response error, returning directly", {
3170
3907
  status: response.status,
3171
3908
  });
@@ -3176,6 +3913,12 @@ export async function AnthropicAuthPlugin({ client }) {
3176
3913
  if (account && accountManager) {
3177
3914
  if (response.ok) {
3178
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
+ }
3179
3922
  }
3180
3923
  }
3181
3924
 
@@ -3185,6 +3928,37 @@ export async function AnthropicAuthPlugin({ client }) {
3185
3928
  const usageCallback = shouldInspectStream
3186
3929
  ? (/** @type {UsageStats} */ usage) => {
3187
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
+ }
3188
3962
  }
3189
3963
  : null;
3190
3964