opencode-anthropic-fix 0.0.27 → 0.0.28

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
@@ -1314,6 +1314,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1314
1314
  // Quiet mode suppresses non-error toasts
1315
1315
  if (config.toasts.quiet && variant !== "error") return;
1316
1316
 
1317
+ // Normalize variant to values OpenCode TUI supports (success, error, info).
1318
+ // "warning" is not a supported variant and causes silent failures.
1319
+ const normalizedVariant = variant === "warning" ? "info" : variant;
1320
+
1317
1321
  // Debounce configured toast categories to reduce chatter.
1318
1322
  if (variant !== "error" && options.debounceKey) {
1319
1323
  const minGapMs = Math.max(0, config.toasts.debounce_seconds) * 1000;
@@ -1335,7 +1339,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1335
1339
  }
1336
1340
 
1337
1341
  try {
1338
- await client.tui?.showToast({ body: { message, variant } });
1342
+ await client.tui?.showToast({ body: { message, variant: normalizedVariant } });
1339
1343
  } catch {
1340
1344
  // TUI may not be available
1341
1345
  }
@@ -1801,6 +1805,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1801
1805
 
1802
1806
  let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
1803
1807
  let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
1808
+ let consecutive529Count = 0;
1804
1809
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1805
1810
  // Select account — use pinned account on first attempt if available
1806
1811
  const account =
@@ -1960,7 +1965,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1960
1965
  _reqHasFileRefs,
1961
1966
  );
1962
1967
 
1963
- const body = transformRequestBody(
1968
+ let body = transformRequestBody(
1964
1969
  requestInit.body,
1965
1970
  {
1966
1971
  enabled: getSignatureEmulationEnabled(),
@@ -1969,6 +1974,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1969
1974
  provider: _reqProvider,
1970
1975
  cachePolicy: config.cache_policy || { ttl: "1h", ttl_supported: true },
1971
1976
  fastMode: config.fast_mode || false,
1977
+ strategy: getEffectiveStrategy(),
1972
1978
  },
1973
1979
  {
1974
1980
  persistentUserId: signatureUserId,
@@ -2076,6 +2082,47 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2076
2082
  { debounceKey: "quota-warn" },
2077
2083
  ).catch(() => {});
2078
2084
  }
2085
+
2086
+ // Predictive rate limit avoidance: switch account BEFORE hitting 429
2087
+ // Parse reset timestamps to compute time-weighted risk
2088
+ if (maxUtilization > 0.6 && accountManager.getAccountCount() > 1) {
2089
+ let highestRisk = 0;
2090
+ for (const subtype of RATE_LIMIT_SUBTYPES) {
2091
+ const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-utilization`);
2092
+ const resetAtStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-reset`);
2093
+ if (!utilizationStr || !resetAtStr) continue;
2094
+
2095
+ const utilization = parseFloat(utilizationStr);
2096
+ const resetAt = new Date(resetAtStr).getTime();
2097
+ if (isNaN(utilization) || isNaN(resetAt)) continue;
2098
+
2099
+ // Estimate window duration from reset time
2100
+ // 5h window = 18000s, 7d window = 604800s
2101
+ const timeUntilReset = Math.max(0, resetAt - Date.now());
2102
+ // Risk formula: how fast we're burning through the quota
2103
+ // Higher utilization + less time remaining = higher risk
2104
+ const timeRemainingFraction = Math.max(0.01, timeUntilReset / (5 * 3600 * 1000)); // assume 5h window
2105
+ const risk = utilization / timeRemainingFraction;
2106
+ if (risk > highestRisk) highestRisk = risk;
2107
+ }
2108
+
2109
+ // Preemptive switch threshold
2110
+ if (highestRisk > 0.85 && accountManager.getAccountCount() > 1) {
2111
+ const currentName = account.email || `Account ${account.index + 1}`;
2112
+ const nextAccount = accountManager.peekNextAccount?.();
2113
+ const nextName = nextAccount?.email || "next account";
2114
+ accountManager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
2115
+ toast(
2116
+ `Predictive switch: ${currentName} at high burn rate, switching to ${nextName}`,
2117
+ "warning",
2118
+ { debounceKey: "predictive-switch" },
2119
+ ).catch(() => {});
2120
+ debugLog("predictive rate limit switch", {
2121
+ accountIndex: account.index,
2122
+ risk: highestRisk.toFixed(2),
2123
+ });
2124
+ }
2125
+ }
2079
2126
  }
2080
2127
 
2081
2128
  // On error, check if it's account-specific or service-wide
@@ -2087,6 +2134,46 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2087
2134
  // Ignore read errors in debug logging path.
2088
2135
  }
2089
2136
 
2137
+ // Reactive compaction: on "prompt too long" error, trim oldest messages and retry once
2138
+ if (
2139
+ response.status === 400 &&
2140
+ errorBody &&
2141
+ (errorBody.includes("prompt is too long") || errorBody.includes("prompt_too_long")) &&
2142
+ !requestInit._reactiveCompactAttempted
2143
+ ) {
2144
+ debugLog("prompt too long — attempting reactive message trimming");
2145
+ try {
2146
+ const parsedBody = JSON.parse(body);
2147
+ if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
2148
+ // Keep first 2 messages (initial context) and last 2 messages (recent work)
2149
+ const trimmed = [
2150
+ ...parsedBody.messages.slice(0, 2),
2151
+ {
2152
+ role: "user",
2153
+ content: [
2154
+ {
2155
+ type: "text",
2156
+ text: "[Earlier conversation was trimmed due to context limits. Continue from the most recent context.]",
2157
+ },
2158
+ ],
2159
+ },
2160
+ ...parsedBody.messages.slice(-2),
2161
+ ];
2162
+ parsedBody.messages = trimmed;
2163
+ requestInit.body = JSON.stringify(parsedBody);
2164
+ requestInit._reactiveCompactAttempted = true;
2165
+ // Retry with trimmed messages (decrement attempt to not consume account slot)
2166
+ attempt--;
2167
+ toast("Context trimmed — retrying with shortened history", "warning", {
2168
+ debounceKey: "compact-retry",
2169
+ }).catch(() => {});
2170
+ continue;
2171
+ }
2172
+ } catch {
2173
+ // If body parse fails, fall through to normal error handling
2174
+ }
2175
+ }
2176
+
2090
2177
  // Auto-disable extended cache TTL if the API rejects it with a 400
2091
2178
  if (
2092
2179
  response.status === 400 &&
@@ -2150,6 +2237,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2150
2237
  // Strategy adaptation: record account-specific throttling signal
2151
2238
  recordRateLimitForStrategy();
2152
2239
 
2240
+ // Graceful degradation: disable fast mode on rate limits
2241
+ if (config.fast_mode && (response.status === 429 || response.status === 529)) {
2242
+ config.fast_mode = false;
2243
+ toast("Fast mode disabled due to rate limiting", "warning", {
2244
+ debounceKey: "fast-mode-off",
2245
+ }).catch(() => {});
2246
+ debugLog("auto-disabled fast mode after rate limit");
2247
+ }
2248
+
2153
2249
  const accountName = account.email || `Account ${account.index + 1}`;
2154
2250
  const lowerBody = String(errorBody || "").toLowerCase();
2155
2251
  const switchMsg =
@@ -2170,6 +2266,43 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2170
2266
  // per RE doc §5.5 (Stainless SDK retries 500+ codes up to 2 times)
2171
2267
  if ((response.status === 529 || response.status === 503) && serviceWideRetryCount < 2) {
2172
2268
  serviceWideRetryCount++;
2269
+
2270
+ // Track consecutive 529s for model fallback
2271
+ if (response.status === 529) {
2272
+ consecutive529Count++;
2273
+ if (consecutive529Count >= 3 && body) {
2274
+ try {
2275
+ const parsedForFallback = JSON.parse(body);
2276
+ const currentModel = parsedForFallback.model || "";
2277
+ let fallbackModel = null;
2278
+ if (/opus-4-6|opus-4/i.test(currentModel))
2279
+ fallbackModel = currentModel.replace(/opus/i, "sonnet");
2280
+ else if (/sonnet-4-6|sonnet-4/i.test(currentModel))
2281
+ fallbackModel = currentModel.replace(/sonnet/i, "haiku");
2282
+
2283
+ if (fallbackModel) {
2284
+ parsedForFallback.model = fallbackModel;
2285
+ requestInit.body = JSON.stringify(parsedForFallback);
2286
+ body = requestInit.body;
2287
+ toast(
2288
+ `Model fallback: ${currentModel} → ${fallbackModel} after ${consecutive529Count} overloads`,
2289
+ "warning",
2290
+ { debounceKey: "model-fallback" },
2291
+ ).catch(() => {});
2292
+ debugLog("model fallback on consecutive 529", {
2293
+ from: currentModel,
2294
+ to: fallbackModel,
2295
+ count: consecutive529Count,
2296
+ });
2297
+ }
2298
+ } catch {
2299
+ /* ignore parse errors */
2300
+ }
2301
+ }
2302
+ } else {
2303
+ consecutive529Count = 0;
2304
+ }
2305
+
2173
2306
  const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 8);
2174
2307
  const jitter = 1 - Math.random() * 0.25;
2175
2308
  const sleepMs = Math.round(baseDelay * jitter * 1000);
@@ -2419,6 +2552,28 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2419
2552
  },
2420
2553
  ],
2421
2554
  },
2555
+ "experimental.session.compacting": async (input, output) => {
2556
+ // Inject Anthropic-specific context into compaction
2557
+ if (!accountManager) return;
2558
+ const account = accountManager.getCurrentAccount();
2559
+ const name = account?.email || "unknown";
2560
+ const q = sessionMetrics.lastQuota;
2561
+ const contextParts = [];
2562
+
2563
+ contextParts.push(`## Anthropic Account State
2564
+ - Active account: ${name}
2565
+ - Session cost: $${sessionMetrics.sessionCostUsd.toFixed(4)}
2566
+ - Turns: ${sessionMetrics.turns}
2567
+ - Cache hit rate: ${(getAverageCacheHitRate() * 100).toFixed(0)}%`);
2568
+
2569
+ if (q.updatedAt > 0) {
2570
+ contextParts.push(
2571
+ `- Rate limit utilization: tokens=${(q.tokens * 100).toFixed(0)}%, requests=${(q.requests * 100).toFixed(0)}%`,
2572
+ );
2573
+ }
2574
+
2575
+ output.context.push(contextParts.join("\n"));
2576
+ },
2422
2577
  };
2423
2578
  }
2424
2579
 
@@ -3887,6 +4042,25 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
3887
4042
  };
3888
4043
  }
3889
4044
 
4045
+ // Cache breakpoint optimization: add cache_control to the last content block
4046
+ // of each user/assistant message for maximum prefix caching.
4047
+ // Skip for round-robin strategy (cache defeated by account rotation).
4048
+ if (signature.enabled && signature.cachePolicy?.ttl !== "off" && signature.cachePolicy?.ttl_supported !== false) {
4049
+ const strategy = signature.strategy || "sticky";
4050
+ if (strategy !== "round-robin" && Array.isArray(parsed.messages)) {
4051
+ const cacheControl = { type: "ephemeral", ttl: signature.cachePolicy?.ttl || "1h" };
4052
+ for (const msg of parsed.messages) {
4053
+ if (!msg.content || !Array.isArray(msg.content) || msg.content.length === 0) continue;
4054
+ // Only cache user and assistant messages (not tool results which change frequently)
4055
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
4056
+ const lastBlock = msg.content[msg.content.length - 1];
4057
+ if (lastBlock && typeof lastBlock === "object") {
4058
+ lastBlock.cache_control = cacheControl;
4059
+ }
4060
+ }
4061
+ }
4062
+ }
4063
+
3890
4064
  // Add prefix to tools definitions
3891
4065
  if (parsed.tools && Array.isArray(parsed.tools)) {
3892
4066
  parsed.tools = parsed.tools.map((tool) => ({
package/lib/accounts.mjs CHANGED
@@ -222,6 +222,18 @@ export class AccountManager {
222
222
  return true;
223
223
  }
224
224
 
225
+ /**
226
+ * Peek at the next account that would be selected without actually switching.
227
+ * Used for predictive rate limit avoidance messaging.
228
+ * @returns {ManagedAccount | null}
229
+ */
230
+ peekNextAccount() {
231
+ const enabled = this.#accounts.filter((acc) => acc.enabled && acc.index !== this.#currentIndex);
232
+ if (enabled.length === 0) return null;
233
+ // Return the first enabled account that isn't the current one
234
+ return enabled[0] ?? null;
235
+ }
236
+
225
237
  /**
226
238
  * Get enabled account references for internal plugin operations.
227
239
  * Returned objects are mutable managed accounts.
package/lib/storage.mjs CHANGED
@@ -206,13 +206,13 @@ export async function loadAccounts() {
206
206
  // rather than silently discarding all accounts
207
207
  if (typeof data.version === "number" && data.version > CURRENT_VERSION) {
208
208
  // Forward-compat: newer versions likely only add fields
209
-
209
+
210
210
  console.warn(
211
211
  `[anthropic-auth] accounts file version ${data.version} is newer than expected ${CURRENT_VERSION}; loading with best effort`,
212
212
  );
213
213
  } else {
214
214
  // Unknown/older version — still try to load
215
-
215
+
216
216
  console.warn(
217
217
  `[anthropic-auth] accounts file version mismatch (${data.version} vs ${CURRENT_VERSION}); attempting load`,
218
218
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [