opencode-anthropic-fix 0.1.27 → 0.1.29
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 +490 -69
- package/lib/accounts.mjs +52 -13
- package/lib/accounts.test.mjs +36 -1
- package/lib/config.mjs +43 -12
- package/lib/request-headers.mjs +52 -3
- package/lib/request-headers.test.mjs +2 -2
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { randomBytes, randomUUID, createHash as createHashCrypto } from "node:cr
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, resolve, basename } from "node:path";
|
|
6
6
|
import xxhashInit from "xxhash-wasm";
|
|
7
|
-
import { AccountManager } from "./lib/accounts.mjs";
|
|
7
|
+
import { AccountManager, RATE_LIMIT_KEY_FAST } from "./lib/accounts.mjs";
|
|
8
8
|
import {
|
|
9
9
|
authorize as oauthAuthorize,
|
|
10
10
|
exchange as oauthExchange,
|
|
@@ -37,6 +37,11 @@ import { callHaiku } from "./lib/haiku-call.mjs";
|
|
|
37
37
|
import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
|
|
38
38
|
import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
|
|
39
39
|
|
|
40
|
+
// Max times a single logical request may fall back from fast->standard speed on
|
|
41
|
+
// the same account before giving up the fast attempt entirely. 1 is enough: one
|
|
42
|
+
// fast 429 => one standard retry on the same account.
|
|
43
|
+
const MAX_FAST_FALLBACKS = 1;
|
|
44
|
+
|
|
40
45
|
// ---------------------------------------------------------------------------
|
|
41
46
|
// Account management CLI prompts
|
|
42
47
|
// ---------------------------------------------------------------------------
|
|
@@ -2461,7 +2466,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2461
2466
|
if (
|
|
2462
2467
|
config.override_model_limits.enabled &&
|
|
2463
2468
|
!isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
|
|
2464
|
-
(hasOneMillionContext(model.id) ||
|
|
2469
|
+
(hasOneMillionContext(model.id) ||
|
|
2470
|
+
isOpus46Model(model.id) ||
|
|
2471
|
+
isOpus47Model(model.id) ||
|
|
2472
|
+
isOpus48Model(model.id))
|
|
2465
2473
|
) {
|
|
2466
2474
|
model.limit = {
|
|
2467
2475
|
...(model.limit ?? {}),
|
|
@@ -2642,6 +2650,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2642
2650
|
let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
|
|
2643
2651
|
let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
|
|
2644
2652
|
let consecutive529Count = 0;
|
|
2653
|
+
let fastFallbackCount = 0; // Track fast->standard same-account fallbacks (cap below)
|
|
2645
2654
|
// Classify request for retry budget (A8)
|
|
2646
2655
|
const requestClass =
|
|
2647
2656
|
config.request_classification?.enabled !== false ? classifyApiRequest(requestInit.body) : "foreground";
|
|
@@ -2682,7 +2691,17 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2682
2691
|
"No enabled Anthropic accounts available. Enable one with 'opencode-anthropic-auth enable <N>'.",
|
|
2683
2692
|
);
|
|
2684
2693
|
}
|
|
2685
|
-
// All accounts excluded (transient refresh failures) — give up
|
|
2694
|
+
// All accounts excluded (transient refresh failures) — give up.
|
|
2695
|
+
// Diagnostic: surface WHICH accounts were skipped and why, so the
|
|
2696
|
+
// "No available account" symptom is debuggable (e.g. it can be a
|
|
2697
|
+
// downstream effect of an OAuth refresh failure, not a true
|
|
2698
|
+
// exhaustion). transientRefreshSkips holds account indices that
|
|
2699
|
+
// failed/were-rate-limited during refresh this request.
|
|
2700
|
+
debugLog("No available account — all candidates transiently skipped", {
|
|
2701
|
+
enabledCount,
|
|
2702
|
+
transientRefreshSkips: Array.from(transientRefreshSkips),
|
|
2703
|
+
attempt,
|
|
2704
|
+
});
|
|
2686
2705
|
throw new Error("No available Anthropic account for request.");
|
|
2687
2706
|
}
|
|
2688
2707
|
|
|
@@ -2847,6 +2866,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2847
2866
|
// suppress context-hint for non-main-thread requests so we don't
|
|
2848
2867
|
// opt subagent/title/tool one-shots into a server retry loop.
|
|
2849
2868
|
const _requestRole = classifyRequestRole(_parsedBodyOnce);
|
|
2869
|
+
// opencode tags subagent requests with the x-parent-session-id header
|
|
2870
|
+
// (set in its request builder only when a parent session exists).
|
|
2871
|
+
// This is a reliable subagent signal; body-shape classification alone
|
|
2872
|
+
// sees subagents as "main" (real messages + large max_tokens). Used
|
|
2873
|
+
// only to pick the cheaper 5m cache tier for one-shot subagents.
|
|
2874
|
+
const _isSubagent = getIncomingHeader(input, requestInit, "x-parent-session-id") != null;
|
|
2850
2875
|
const _baseTE = config.token_economy || {};
|
|
2851
2876
|
const _disableCtxHint = contextHintState.disabled || _requestRole !== "main";
|
|
2852
2877
|
const _tokenEconomy = _disableCtxHint
|
|
@@ -2979,6 +3004,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2979
3004
|
provider: _reqProvider,
|
|
2980
3005
|
cachePolicy: effectiveCachePolicy,
|
|
2981
3006
|
fastMode: config.fast_mode || false,
|
|
3007
|
+
// When the account's fast pool is cooling down, suppress
|
|
3008
|
+
// speed:"fast" so this turn runs at standard speed on the same
|
|
3009
|
+
// account instead of re-hitting the exhausted fast pool.
|
|
3010
|
+
fastRateLimited: accountManager.isFastRateLimited(account),
|
|
2982
3011
|
strategy: getEffectiveStrategy(),
|
|
2983
3012
|
toolDeferral: config.token_economy_strategies?.tool_deferral,
|
|
2984
3013
|
toolDescriptionCompaction: config.token_economy_strategies?.tool_description_compaction,
|
|
@@ -2995,6 +3024,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2995
3024
|
usedTools: sessionMetrics.usedTools,
|
|
2996
3025
|
tokenEconomySession,
|
|
2997
3026
|
requestRole: _requestRole,
|
|
3027
|
+
isSubagent: _isSubagent,
|
|
3028
|
+
// Adaptive cache-breakpoint placement: pass the per-boundary
|
|
3029
|
+
// stability snapshot so transformRequestBody can anchor the
|
|
3030
|
+
// cache_control marker on the most-stable boundary instead of
|
|
3031
|
+
// the (possibly thrashing) last tool. Only populated once the
|
|
3032
|
+
// detector is enabled and has a baseline.
|
|
3033
|
+
cacheBoundaryStability: config.cache_break_detection?.adaptive_breakpoint
|
|
3034
|
+
? cacheBreakState.boundaryStability
|
|
3035
|
+
: null,
|
|
2998
3036
|
},
|
|
2999
3037
|
computedBetaHeader,
|
|
3000
3038
|
config,
|
|
@@ -3018,6 +3056,16 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3018
3056
|
if (config.cache_break_detection?.enabled && typeof body === "string") {
|
|
3019
3057
|
const currentHashes = extractCacheSourceHashes(body);
|
|
3020
3058
|
if (currentHashes.size > 0) {
|
|
3059
|
+
// Update per-boundary structural stability BEFORE staging the
|
|
3060
|
+
// new hashes, diffing against the prior turn's baseline. This
|
|
3061
|
+
// feeds the adaptive breakpoint placer on the NEXT turn.
|
|
3062
|
+
if (cacheBreakState.sourceHashes.size > 0) {
|
|
3063
|
+
updateBoundaryStability(
|
|
3064
|
+
currentHashes,
|
|
3065
|
+
cacheBreakState.sourceHashes,
|
|
3066
|
+
cacheBreakState.boundaryStability,
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3021
3069
|
cacheBreakState._pendingHashes = currentHashes;
|
|
3022
3070
|
}
|
|
3023
3071
|
}
|
|
@@ -3665,8 +3713,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3665
3713
|
// Check x-should-retry header first — server override
|
|
3666
3714
|
const shouldRetry = parseShouldRetryHeader(response);
|
|
3667
3715
|
if (shouldRetry === false) {
|
|
3668
|
-
// Server says DO NOT retry — return error directly
|
|
3669
|
-
|
|
3716
|
+
// Server says DO NOT retry — return error directly.
|
|
3717
|
+
// Include a snippet of the error body so 4xx causes (e.g. the
|
|
3718
|
+
// thinking-block "cannot be modified" 400, or an unsupported
|
|
3719
|
+
// manual-thinking 400 on adaptive models) are visible in debug.
|
|
3720
|
+
debugLog("x-should-retry: false — not retrying", {
|
|
3721
|
+
status: response.status,
|
|
3722
|
+
errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
|
|
3723
|
+
});
|
|
3670
3724
|
return transformResponse(response);
|
|
3671
3725
|
}
|
|
3672
3726
|
|
|
@@ -3713,6 +3767,44 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3713
3767
|
continue;
|
|
3714
3768
|
}
|
|
3715
3769
|
|
|
3770
|
+
// Fast-mode pool isolation: if THIS request used speed:"fast"
|
|
3771
|
+
// and the limit is a plain rate limit, the FAST pool was
|
|
3772
|
+
// exhausted — not the account's standard quota. Per Anthropic,
|
|
3773
|
+
// fast has a separate pool and should fall back to standard
|
|
3774
|
+
// speed, NOT block the account. So: cool down only the fast
|
|
3775
|
+
// bucket, then retry on the SAME account at standard speed
|
|
3776
|
+
// (the next transformRequestBody omits speed:"fast" because
|
|
3777
|
+
// isFastRateLimited(account) is now true). We do NOT flip the
|
|
3778
|
+
// global config.fast_mode flag — other accounts/turns can still
|
|
3779
|
+
// use fast mode.
|
|
3780
|
+
const requestWasFast = typeof body === "string" && body.includes('"speed":"fast"');
|
|
3781
|
+
if (
|
|
3782
|
+
requestWasFast &&
|
|
3783
|
+
response.status === 429 &&
|
|
3784
|
+
reason === "RATE_LIMIT_EXCEEDED" &&
|
|
3785
|
+
fastFallbackCount < MAX_FAST_FALLBACKS
|
|
3786
|
+
) {
|
|
3787
|
+
fastFallbackCount++;
|
|
3788
|
+
const fastBackoff = accountManager.markRateLimited(
|
|
3789
|
+
account,
|
|
3790
|
+
reason,
|
|
3791
|
+
retryAfterMs,
|
|
3792
|
+
RATE_LIMIT_KEY_FAST,
|
|
3793
|
+
);
|
|
3794
|
+
_fastModeAppliedToast = false;
|
|
3795
|
+
toast("⚡ Fast pool limited — falling back to standard speed", "info", {
|
|
3796
|
+
debounceKey: "fast-fallback",
|
|
3797
|
+
}).catch(() => {});
|
|
3798
|
+
debugLog("fast pool rate-limited; falling back to standard on same account", {
|
|
3799
|
+
account: account.email || `Account ${account.index + 1}`,
|
|
3800
|
+
fastBackoffMs: fastBackoff,
|
|
3801
|
+
fastFallbackCount,
|
|
3802
|
+
});
|
|
3803
|
+
// Retry same account at standard speed without consuming a slot.
|
|
3804
|
+
attempt--;
|
|
3805
|
+
continue;
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3716
3808
|
accountManager.markRateLimited(account, reason, retryAfterMs);
|
|
3717
3809
|
|
|
3718
3810
|
// On auth failures, clear token so next selection forces refresh
|
|
@@ -3724,7 +3816,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3724
3816
|
// Strategy adaptation: record account-specific throttling signal
|
|
3725
3817
|
recordRateLimitForStrategy();
|
|
3726
3818
|
|
|
3727
|
-
// Graceful degradation: disable fast mode on rate limits
|
|
3819
|
+
// Graceful degradation: disable fast mode on rate limits.
|
|
3820
|
+
// Note: a fast-pool-only 429 is handled by the fast-fallback
|
|
3821
|
+
// branch above and never reaches here, so this global disable
|
|
3822
|
+
// only fires for standard-pool limits or 529 overloads.
|
|
3728
3823
|
if (config.fast_mode && (response.status === 429 || response.status === 529)) {
|
|
3729
3824
|
config.fast_mode = false;
|
|
3730
3825
|
_fastModeAppliedToast = false;
|
|
@@ -4372,8 +4467,40 @@ const cacheBreakState = {
|
|
|
4372
4467
|
prevCacheRead: 0,
|
|
4373
4468
|
sourceHashes: new Map(),
|
|
4374
4469
|
lastAlertTurn: 0,
|
|
4470
|
+
/**
|
|
4471
|
+
* Per-boundary structural stability: how many consecutive turns each cache
|
|
4472
|
+
* source ("system_prompt", "tools", "messages_prefix") has been unchanged.
|
|
4473
|
+
* Used by the adaptive breakpoint placer to anchor the cache_control marker
|
|
4474
|
+
* on the most-stable boundary instead of blindly on the last tool (which
|
|
4475
|
+
* thrashes when the host reorders/adds tools between turns).
|
|
4476
|
+
* @type {Map<string, number>}
|
|
4477
|
+
*/
|
|
4478
|
+
boundaryStability: new Map(),
|
|
4375
4479
|
};
|
|
4376
4480
|
|
|
4481
|
+
/**
|
|
4482
|
+
* Update per-boundary stability counters by diffing the current request's cache
|
|
4483
|
+
* source hashes against the previous turn's. A boundary that is unchanged gets
|
|
4484
|
+
* its counter incremented; a changed (or newly-appeared) boundary resets to 0.
|
|
4485
|
+
*
|
|
4486
|
+
* @param {Map<string,string>} current - hashes for this turn (source -> hash)
|
|
4487
|
+
* @param {Map<string,string>} previous - hashes from the prior turn
|
|
4488
|
+
* @param {Map<string,number>} stability - mutated in place
|
|
4489
|
+
*/
|
|
4490
|
+
function updateBoundaryStability(current, previous, stability) {
|
|
4491
|
+
for (const [source, hash] of current) {
|
|
4492
|
+
if (previous.get(source) === hash) {
|
|
4493
|
+
stability.set(source, (stability.get(source) || 0) + 1);
|
|
4494
|
+
} else {
|
|
4495
|
+
stability.set(source, 0);
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
// Drop boundaries that no longer appear so stale entries don't linger.
|
|
4499
|
+
for (const source of [...stability.keys()]) {
|
|
4500
|
+
if (!current.has(source)) stability.delete(source);
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4377
4504
|
// ---------------------------------------------------------------------------
|
|
4378
4505
|
// Microcompact state (Phase 3, Task 3.4)
|
|
4379
4506
|
// ---------------------------------------------------------------------------
|
|
@@ -4603,6 +4730,116 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
|
|
|
4603
4730
|
* @param {string} bodyStr - JSON request body
|
|
4604
4731
|
* @returns {Map<string, string>} source_id → hash
|
|
4605
4732
|
*/
|
|
4733
|
+
/**
|
|
4734
|
+
* Resolve the prompt-cache TTL for tool/message breakpoints, mirroring the
|
|
4735
|
+
* decompiled Claude Code 2.1.154 `REH(querySource)` precedence.
|
|
4736
|
+
*
|
|
4737
|
+
* Precedence (highest first):
|
|
4738
|
+
* 1. FORCE_PROMPT_CACHING_5M truthy -> "5m"
|
|
4739
|
+
* 2. ENABLE_PROMPT_CACHING_1H truthy -> "1h"
|
|
4740
|
+
* 3. role-scoping on AND not the main role -> "5m" (side-queries are cheap)
|
|
4741
|
+
* 4. otherwise -> configuredTtl (main interactive thread, default "1h")
|
|
4742
|
+
*
|
|
4743
|
+
* @param {object} args
|
|
4744
|
+
* @param {string} args.configuredTtl - Default TTL when 1h is eligible (usually "1h").
|
|
4745
|
+
* @param {boolean} args.roleScopedTtl - Whether role-scoped 5m downgrade is enabled.
|
|
4746
|
+
* @param {boolean} args.isMainForCache - True when the request is the main/interactive role.
|
|
4747
|
+
* @param {boolean} [args.isSubagent] - True when the request is an opencode subagent
|
|
4748
|
+
* (detected via the `x-parent-session-id` header). One-shot subagents prefer 5m.
|
|
4749
|
+
* @param {Record<string, string | undefined>} [args.env] - Environment (defaults to process.env).
|
|
4750
|
+
* @returns {string} The TTL string ("5m" or "1h"/configuredTtl).
|
|
4751
|
+
*/
|
|
4752
|
+
function resolveCacheTtl({ configuredTtl, roleScopedTtl, isMainForCache, isSubagent = false, env = process.env }) {
|
|
4753
|
+
// Env overrides (exact CC REH() behavior). isTruthyEnv mirrors CC's uH().
|
|
4754
|
+
if (isTruthyEnv(env.FORCE_PROMPT_CACHING_5M)) return "5m";
|
|
4755
|
+
if (isTruthyEnv(env.ENABLE_PROMPT_CACHING_1H)) return "1h";
|
|
4756
|
+
// Subagent requests (opencode marks them with the x-parent-session-id header)
|
|
4757
|
+
// are usually one-shot, and opencode runs them sequentially with a DIFFERENT
|
|
4758
|
+
// system prompt than the main thread, so they rarely re-hit a 1h cache. Prefer
|
|
4759
|
+
// the cheap 5m write tier (~1.25x base) over the 1h tier (~2x base) to avoid
|
|
4760
|
+
// paying the long-cache write premium on a prefix that won't be reused. Gated
|
|
4761
|
+
// on the same role-scoped master switch as the side-query downgrade below.
|
|
4762
|
+
if (roleScopedTtl && isSubagent) return "5m";
|
|
4763
|
+
// Role-scoped downgrade: non-main (side-query) requests use the cheap 5m tier.
|
|
4764
|
+
if (roleScopedTtl && !isMainForCache) return "5m";
|
|
4765
|
+
return configuredTtl;
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
/**
|
|
4769
|
+
* Read a single incoming request header by name (case-insensitive) from either a
|
|
4770
|
+
* `Request` input or a fetch `init.headers` (Headers | array | plain object).
|
|
4771
|
+
* Returns the trimmed value or null. Used to detect opencode's subagent marker
|
|
4772
|
+
* `x-parent-session-id` without consuming/normalizing the full header set.
|
|
4773
|
+
* @param {any} input
|
|
4774
|
+
* @param {any} requestInit
|
|
4775
|
+
* @param {string} name
|
|
4776
|
+
* @returns {string | null}
|
|
4777
|
+
*/
|
|
4778
|
+
function getIncomingHeader(input, requestInit, name) {
|
|
4779
|
+
const lower = name.toLowerCase();
|
|
4780
|
+
const pick = (v) => (v != null && String(v).trim() !== "" ? String(v).trim() : null);
|
|
4781
|
+
try {
|
|
4782
|
+
if (input && typeof input === "object" && input.headers && typeof input.headers.get === "function") {
|
|
4783
|
+
const v = pick(input.headers.get(name));
|
|
4784
|
+
if (v) return v;
|
|
4785
|
+
}
|
|
4786
|
+
} catch {
|
|
4787
|
+
// malformed Request — fall through to init.headers
|
|
4788
|
+
}
|
|
4789
|
+
const h = requestInit?.headers;
|
|
4790
|
+
if (!h) return null;
|
|
4791
|
+
try {
|
|
4792
|
+
if (typeof h.get === "function") return pick(h.get(name));
|
|
4793
|
+
if (Array.isArray(h)) {
|
|
4794
|
+
for (const pair of h) {
|
|
4795
|
+
if (Array.isArray(pair) && String(pair[0]).toLowerCase() === lower) return pick(pair[1]);
|
|
4796
|
+
}
|
|
4797
|
+
return null;
|
|
4798
|
+
}
|
|
4799
|
+
for (const [k, v] of Object.entries(h)) {
|
|
4800
|
+
if (k.toLowerCase() === lower) return pick(v);
|
|
4801
|
+
}
|
|
4802
|
+
} catch {
|
|
4803
|
+
// unexpected header shape — treat as absent
|
|
4804
|
+
}
|
|
4805
|
+
return null;
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
/**
|
|
4809
|
+
* Decide whether to place the prompt-cache breakpoint on the last tool.
|
|
4810
|
+
*
|
|
4811
|
+
* Default (no stability data, or detector disabled): TRUE — exact Claude Code
|
|
4812
|
+
* behavior (breakpoint on the last tool).
|
|
4813
|
+
*
|
|
4814
|
+
* Adaptive override: returns FALSE only when there is concrete evidence that the
|
|
4815
|
+
* tool boundary is thrashing (a `tool:*` source changed within the last turn,
|
|
4816
|
+
* i.e. stability 0) WHILE the system-prompt boundary is stable (unchanged for
|
|
4817
|
+
* >= STABLE_TURNS turns). In that case the stable system-prompt breakpoint is a
|
|
4818
|
+
* better cache anchor than a tool array that moves every turn.
|
|
4819
|
+
*
|
|
4820
|
+
* @param {Map<string, number> | null | undefined} stability - source -> consecutive-unchanged turns
|
|
4821
|
+
* @returns {boolean} whether to stamp cache_control on the last tool
|
|
4822
|
+
*/
|
|
4823
|
+
function shouldPlaceToolBreakpoint(stability) {
|
|
4824
|
+
if (!stability || stability.size === 0) return true; // no data -> CC behavior
|
|
4825
|
+
const STABLE_TURNS = 2;
|
|
4826
|
+
const systemStability = stability.get("system_prompt");
|
|
4827
|
+
const systemIsStable = typeof systemStability === "number" && systemStability >= STABLE_TURNS;
|
|
4828
|
+
if (!systemIsStable) return true; // system not proven stable -> keep CC behavior
|
|
4829
|
+
|
|
4830
|
+
// Is any tool boundary thrashing right now (changed within the last turn)?
|
|
4831
|
+
let toolThrashing = false;
|
|
4832
|
+
for (const [source, turns] of stability) {
|
|
4833
|
+
if (source.startsWith("tool:") && turns === 0) {
|
|
4834
|
+
toolThrashing = true;
|
|
4835
|
+
break;
|
|
4836
|
+
}
|
|
4837
|
+
}
|
|
4838
|
+
// Skip the last-tool breakpoint only when tools thrash AND system is the
|
|
4839
|
+
// stable anchor. Otherwise fall back to CC behavior.
|
|
4840
|
+
return !toolThrashing;
|
|
4841
|
+
}
|
|
4842
|
+
|
|
4606
4843
|
function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
|
|
4607
4844
|
const hashes = new Map();
|
|
4608
4845
|
try {
|
|
@@ -5141,6 +5378,11 @@ function forceEscalateAdaptiveContext() {
|
|
|
5141
5378
|
}
|
|
5142
5379
|
|
|
5143
5380
|
const MODEL_PRICING = {
|
|
5381
|
+
// Opus 4.8 (launched 2026-05-28): $5/$25 per 1M (input/output), notably
|
|
5382
|
+
// cheaper than 4.6/4.7. cacheRead = 0.1x input, cacheWrite = 1.25x input
|
|
5383
|
+
// (Anthropic's standard 5m-write ratio).
|
|
5384
|
+
"claude-opus-4-8": { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
5385
|
+
"claude-opus-4-7": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
5144
5386
|
"claude-opus-4-6": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
5145
5387
|
"claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
5146
5388
|
"claude-haiku-4-5": { input: 0.8, output: 4.0, cacheRead: 0.08, cacheWrite: 1.0 },
|
|
@@ -6245,8 +6487,8 @@ function extractFirstUserMessageText(messages) {
|
|
|
6245
6487
|
}
|
|
6246
6488
|
|
|
6247
6489
|
const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
|
|
6248
|
-
const
|
|
6249
|
-
const
|
|
6490
|
+
const _EFFORT_BETA_FLAG = "effort-2025-11-24";
|
|
6491
|
+
const _ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
|
|
6250
6492
|
const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
|
|
6251
6493
|
const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
|
|
6252
6494
|
const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
@@ -6481,6 +6723,20 @@ function isOpus47Model(model) {
|
|
|
6481
6723
|
return /claude-opus-4[._-]7|opus[._-]4[._-]7/i.test(model);
|
|
6482
6724
|
}
|
|
6483
6725
|
|
|
6726
|
+
/**
|
|
6727
|
+
* Detects claude-opus-4.8 / claude-opus-4-8 model IDs.
|
|
6728
|
+
* Opus 4.8 (launched 2026-05-28) is an adaptive-thinking model: manual
|
|
6729
|
+
* `thinking: {type: "enabled", budget_tokens}` returns a 400 — it MUST use
|
|
6730
|
+
* `thinking: {type: "adaptive"}` + the effort parameter. It also supports
|
|
6731
|
+
* `speed: "fast"` (fast-mode research preview) and 1M context by default.
|
|
6732
|
+
* @param {string | undefined} model
|
|
6733
|
+
* @returns {boolean}
|
|
6734
|
+
*/
|
|
6735
|
+
function isOpus48Model(model) {
|
|
6736
|
+
if (!model) return false;
|
|
6737
|
+
return /claude-opus-4[._-]8|opus[._-]4[._-]8/i.test(model);
|
|
6738
|
+
}
|
|
6739
|
+
|
|
6484
6740
|
/**
|
|
6485
6741
|
* Models eligible for the "simple system prompt" mode that real CC ships in
|
|
6486
6742
|
* v2.1.133+ under GrowthBook flag `tengu_vellum_lantern` (or forced via
|
|
@@ -6495,7 +6751,7 @@ function isOpus47Model(model) {
|
|
|
6495
6751
|
*/
|
|
6496
6752
|
function isSimpleSystemPromptEligible(model) {
|
|
6497
6753
|
if (!model) return false;
|
|
6498
|
-
if (isOpus47Model(model)) return true;
|
|
6754
|
+
if (isOpus47Model(model) || isOpus48Model(model)) return true;
|
|
6499
6755
|
// -eap suffix variant, e.g. "claude-opus-4-7-eap" or "claude-opus-4-7-eap[1m]"
|
|
6500
6756
|
if (/-eap(?:\[|$|-)/i.test(model)) return true;
|
|
6501
6757
|
return false;
|
|
@@ -6513,12 +6769,12 @@ function isSonnet46Model(model) {
|
|
|
6513
6769
|
|
|
6514
6770
|
/**
|
|
6515
6771
|
* Detects models that support adaptive thinking ({type: "adaptive"}).
|
|
6516
|
-
* Currently: Opus 4.6, Opus 4.7, and Sonnet 4.6.
|
|
6772
|
+
* Currently: Opus 4.6, Opus 4.7, Opus 4.8, and Sonnet 4.6.
|
|
6517
6773
|
* @param {string | undefined} body
|
|
6518
6774
|
* @returns {boolean}
|
|
6519
6775
|
*/
|
|
6520
6776
|
function isAdaptiveThinkingModel(model) {
|
|
6521
|
-
return isOpus46Model(model) || isOpus47Model(model) || isSonnet46Model(model);
|
|
6777
|
+
return isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model) || isSonnet46Model(model);
|
|
6522
6778
|
}
|
|
6523
6779
|
|
|
6524
6780
|
/**
|
|
@@ -6533,8 +6789,11 @@ function isEligibleFor1MContext(model) {
|
|
|
6533
6789
|
// Explicit 1m suffix/tag in model name
|
|
6534
6790
|
if (/(^|[-_ ])1m($|[-_ ])|context[-_]?1m|\[1m\]/i.test(model)) return true;
|
|
6535
6791
|
// CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6.
|
|
6536
|
-
// Opus 4.7 (
|
|
6537
|
-
|
|
6792
|
+
// Opus 4.7 / 4.8 (successors to 4.6) are also 1M-context eligible.
|
|
6793
|
+
// Opus 4.8 ships with 1M context by default on the Claude API.
|
|
6794
|
+
return (
|
|
6795
|
+
/claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model)
|
|
6796
|
+
);
|
|
6538
6797
|
}
|
|
6539
6798
|
|
|
6540
6799
|
/**
|
|
@@ -7309,6 +7568,11 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
7309
7568
|
* @param {string} [requestPath]
|
|
7310
7569
|
* @param {boolean} [hasFileReferences]
|
|
7311
7570
|
* @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
|
|
7571
|
+
* @param {boolean} [fastModeActive] - When true, emits FAST_MODE_BETA_FLAG (fast-mode-2026-02-01).
|
|
7572
|
+
* Must be derived structurally from the already-transformed outgoing body (body.includes('"speed":"fast"')).
|
|
7573
|
+
* Only passed from the buildRequestHeaders call site (after body transform). The pre-transform
|
|
7574
|
+
* computedBetaHeader call site (index.mjs:2905) leaves this undefined so the latch never
|
|
7575
|
+
* accumulates fast-mode independently of actual body content.
|
|
7312
7576
|
* @returns {string}
|
|
7313
7577
|
*/
|
|
7314
7578
|
function buildAnthropicBetaHeader(
|
|
@@ -7323,6 +7587,7 @@ function buildAnthropicBetaHeader(
|
|
|
7323
7587
|
adaptiveOverride,
|
|
7324
7588
|
tokenEconomy,
|
|
7325
7589
|
microcompactBetas, // NEW 11th param
|
|
7590
|
+
fastModeActive, // NEW 12th param — see @param above
|
|
7326
7591
|
) {
|
|
7327
7592
|
const incomingBetasList = incomingBeta
|
|
7328
7593
|
.split(",")
|
|
@@ -7358,24 +7623,12 @@ function buildAnthropicBetaHeader(
|
|
|
7358
7623
|
// behavior from the Anthropic API.
|
|
7359
7624
|
betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
|
|
7360
7625
|
|
|
7361
|
-
//
|
|
7362
|
-
//
|
|
7363
|
-
// Vertex/Bedrock u2192 tool-search-tool-2025-10-19 (3P-compatible subset)
|
|
7364
|
-
if (provider === "vertex" || provider === "bedrock" || provider === "mantle") {
|
|
7365
|
-
betas.push("tool-search-tool-2025-10-19");
|
|
7366
|
-
} else {
|
|
7367
|
-
betas.push(ADVANCED_TOOL_USE_BETA_FLAG); // "advanced-tool-use-2025-11-20"
|
|
7368
|
-
}
|
|
7626
|
+
// v2.1.150: advanced-tool-use / tool-search-tool removed from always-on.
|
|
7627
|
+
// CC 2.1.150 D5q does not push these per-request. Available via custom betas.
|
|
7369
7628
|
|
|
7370
|
-
|
|
7629
|
+
// v2.1.150: fast-mode-2026-02-01 removed from always-on (CC sends only when speed feature active).
|
|
7371
7630
|
|
|
7372
|
-
// effort-2025-11-24
|
|
7373
|
-
// is true (Opus 4.6 / Sonnet 4.6). Pushing it for non-adaptive models like
|
|
7374
|
-
// Haiku is a fingerprint mismatch vs real CC and can contaminate billing
|
|
7375
|
-
// attribution even when the request body has no effort field.
|
|
7376
|
-
if (isAdaptiveThinkingModel(model)) {
|
|
7377
|
-
betas.push(EFFORT_BETA_FLAG); // "effort-2025-11-24"
|
|
7378
|
-
}
|
|
7631
|
+
// v2.1.150: effort-2025-11-24 removed from always-on (CC sends only when effort is explicitly requested).
|
|
7379
7632
|
|
|
7380
7633
|
// Interleaved thinking — real CC's i01 pushes via hv4(model), which is
|
|
7381
7634
|
// (firstParty && non-Claude-3). Claude 3.x models don't support interleaved
|
|
@@ -7400,18 +7653,20 @@ function buildAnthropicBetaHeader(
|
|
|
7400
7653
|
if (!isRoundRobin) {
|
|
7401
7654
|
betas.push("prompt-caching-scope-2026-01-05");
|
|
7402
7655
|
}
|
|
7656
|
+
// v2.1.150: extended-cache-ttl for better cache hit rates (opt-out via token_economy.extended_cache_ttl = false).
|
|
7657
|
+
if (te.extended_cache_ttl !== false && !isRoundRobin) {
|
|
7658
|
+
betas.push("extended-cache-ttl-2025-04-11");
|
|
7659
|
+
}
|
|
7403
7660
|
|
|
7404
7661
|
// === CONDITIONAL BETAS (model/context-dependent) ===
|
|
7405
7662
|
|
|
7406
|
-
//
|
|
7407
|
-
|
|
7408
|
-
if (!/claude-3-/i.test(model)) {
|
|
7663
|
+
// v2.1.150: context-management gated behind opt-in (CC has hardcoded && false).
|
|
7664
|
+
if (te.context_management && !/claude-3-/i.test(model)) {
|
|
7409
7665
|
betas.push("context-management-2025-06-27");
|
|
7410
7666
|
}
|
|
7411
7667
|
|
|
7412
|
-
//
|
|
7413
|
-
|
|
7414
|
-
if (supportsStructuredOutputs(model)) {
|
|
7668
|
+
// v2.1.150: structured-outputs gated behind opt-in (CC has behind tengu_tool_pear flag).
|
|
7669
|
+
if (te.structured_outputs && supportsStructuredOutputs(model)) {
|
|
7415
7670
|
betas.push("structured-outputs-2025-12-15");
|
|
7416
7671
|
}
|
|
7417
7672
|
|
|
@@ -7445,12 +7700,6 @@ function buildAnthropicBetaHeader(
|
|
|
7445
7700
|
betas.push("context-hint-2026-04-09");
|
|
7446
7701
|
}
|
|
7447
7702
|
|
|
7448
|
-
// extended-cache-ttl-2025-04-11 — found in CC v2.1.143. Extends server-side
|
|
7449
|
-
// prompt cache TTL. Auto-on for 1P + Claude 4+ to improve cache hit rates.
|
|
7450
|
-
if (isFirstPartyProvider && !/claude-3-/i.test(model)) {
|
|
7451
|
-
betas.push("extended-cache-ttl-2025-04-11");
|
|
7452
|
-
}
|
|
7453
|
-
|
|
7454
7703
|
// Files API — scoped to file endpoints/references
|
|
7455
7704
|
if (isFilesEndpoint || hasFileReferences) {
|
|
7456
7705
|
betas.push("files-api-2025-04-14");
|
|
@@ -7463,13 +7712,18 @@ function buildAnthropicBetaHeader(
|
|
|
7463
7712
|
|
|
7464
7713
|
// === TOKEN ECONOMY BETAS (on by default for token savings) ===
|
|
7465
7714
|
|
|
7466
|
-
// redact-thinking
|
|
7467
|
-
//
|
|
7468
|
-
|
|
7469
|
-
if (te.redact_thinking && !disableExperimentalBetas) {
|
|
7715
|
+
// v2.1.150: redact-thinking is default-ON in CC (first-party, non-SDK, thinking models).
|
|
7716
|
+
// Opt out via `/anthropic set redact-thinking off` to see thinking content.
|
|
7717
|
+
if (te.redact_thinking !== false && !disableExperimentalBetas && !/claude-3-/i.test(model)) {
|
|
7470
7718
|
betas.push("redact-thinking-2026-02-12");
|
|
7471
7719
|
}
|
|
7472
7720
|
|
|
7721
|
+
// v2.1.150: thinking-token-count for token budget tracking (behind tengu_chert_bezel in CC).
|
|
7722
|
+
// Default ON for token economy visibility. Opt out via token_economy.thinking_token_count = false.
|
|
7723
|
+
if (te.thinking_token_count !== false && !disableExperimentalBetas && !/claude-3-/i.test(model)) {
|
|
7724
|
+
betas.push("thinking-token-count-2026-05-13");
|
|
7725
|
+
}
|
|
7726
|
+
|
|
7473
7727
|
// compact-2026-01-12 and mcp-client-2025-11-20 exist only in docs, not runtime.
|
|
7474
7728
|
|
|
7475
7729
|
// afk-mode — NOT auto-included (requires user opt-in)
|
|
@@ -7482,6 +7736,16 @@ function buildAnthropicBetaHeader(
|
|
|
7482
7736
|
}
|
|
7483
7737
|
}
|
|
7484
7738
|
|
|
7739
|
+
// Fast-mode beta — the PLUGIN injects speed:"fast" into the body, not the host.
|
|
7740
|
+
// Because the host never sends speed:"fast", it also never sends the matching beta.
|
|
7741
|
+
// We must add it here, derived structurally from the already-transformed body so
|
|
7742
|
+
// it is always in lockstep with the speed field. Added BEFORE the dedupe merge so
|
|
7743
|
+
// any duplicate (e.g. via incoming passthrough) is collapsed without duplication.
|
|
7744
|
+
// Unlike effort-2025-11-24 / advanced-tool-use / tool-search-tool (which arrive via
|
|
7745
|
+
// incoming-header passthrough because the HOST sets those body features), fast-mode
|
|
7746
|
+
// is plugin-only and requires this explicit push.
|
|
7747
|
+
if (fastModeActive) betas.push(FAST_MODE_BETA_FLAG);
|
|
7748
|
+
|
|
7485
7749
|
// Merge incoming betas from the original request, filtering out host-injected
|
|
7486
7750
|
// betas (e.g. fine-grained-tool-streaming-2025-05-14, structured-outputs-2025-11-13)
|
|
7487
7751
|
// that OpenCode's Anthropic SDK adds but real Claude Code never sends.
|
|
@@ -7534,7 +7798,9 @@ function budgetTokensToEffort(budgetTokens) {
|
|
|
7534
7798
|
|
|
7535
7799
|
/**
|
|
7536
7800
|
* Normalise the `thinking` block in the request body for the target model:
|
|
7537
|
-
* - Opus 4.6 / Sonnet 4.6 (adaptive thinking): produces
|
|
7801
|
+
* - Opus 4.6 / 4.7 / 4.8 / Sonnet 4.6 (adaptive thinking): produces
|
|
7802
|
+
* `{ type: "adaptive" }`. This is REQUIRED for Opus 4.7/4.8 — manual
|
|
7803
|
+
* `{ type: "enabled", budget_tokens }` returns a 400 on those models.
|
|
7538
7804
|
* - Older models: passes the existing thinking block through unchanged.
|
|
7539
7805
|
*
|
|
7540
7806
|
* @param {any} thinking
|
|
@@ -7667,6 +7933,14 @@ function buildRequestHeaders(
|
|
|
7667
7933
|
const incomingBeta = requestHeaders.get("anthropic-beta") || "";
|
|
7668
7934
|
const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
|
|
7669
7935
|
const provider = detectProvider(requestUrl);
|
|
7936
|
+
// Detect fast mode structurally from the already-transformed body string.
|
|
7937
|
+
// transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
|
|
7938
|
+
// so speed:"fast" is already present when fast mode fired. Same detection idiom as the
|
|
7939
|
+
// toast check (~line 3018). This is the correct call site for fast-mode: the
|
|
7940
|
+
// computedBetaHeader call at index.mjs:2905 runs BEFORE body transform, so fastModeActive
|
|
7941
|
+
// is always false/absent there, keeping the latch free of fast-mode. The
|
|
7942
|
+
// requestHeaders.set("anthropic-beta", mergedBetas) call below is what reaches the wire.
|
|
7943
|
+
const fastModeActive = typeof requestBody === "string" && requestBody.includes('"speed":"fast"');
|
|
7670
7944
|
const mergedBetas = buildAnthropicBetaHeader(
|
|
7671
7945
|
incomingBeta,
|
|
7672
7946
|
signature.enabled,
|
|
@@ -7678,6 +7952,8 @@ function buildRequestHeaders(
|
|
|
7678
7952
|
hasFileReferences,
|
|
7679
7953
|
adaptiveOverride,
|
|
7680
7954
|
tokenEconomy,
|
|
7955
|
+
undefined, // microcompactBetas: not available at this call site (handled via computedBetaHeader path at 2905)
|
|
7956
|
+
fastModeActive, // NEW 12th param: emit fast-mode-2026-02-01 beta when body contains speed:"fast"
|
|
7681
7957
|
);
|
|
7682
7958
|
|
|
7683
7959
|
const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
@@ -7704,7 +7980,7 @@ function buildRequestHeaders(
|
|
|
7704
7980
|
// the minifier identifier renamed, d66 → g86). Re-verify on every upstream bump:
|
|
7705
7981
|
// rg -n '"0\.\d+\.\d+"' _tmp_claude_pkg/<version>/package/cli.js | rg -C2 stainless
|
|
7706
7982
|
// See docs/future-improvements.md §7 and claude-code-reverse-engineering.md §16.
|
|
7707
|
-
requestHeaders.set("x-stainless-package-version", "0.
|
|
7983
|
+
requestHeaders.set("x-stainless-package-version", "0.94.0");
|
|
7708
7984
|
// Real CC on Windows/Node reports "node" — confirmed via proxy capture.
|
|
7709
7985
|
requestHeaders.set("x-stainless-runtime", "node");
|
|
7710
7986
|
requestHeaders.set("x-stainless-runtime-version", process.version);
|
|
@@ -7792,6 +8068,21 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
7792
8068
|
|
|
7793
8069
|
try {
|
|
7794
8070
|
const parsed = JSON.parse(body);
|
|
8071
|
+
// Diagnostic: log the incoming model + thinking/speed shape so model-routing
|
|
8072
|
+
// issues (e.g. an unrecognized fast variant, or "No available account"
|
|
8073
|
+
// symptoms tied to a specific model) are observable with /anthropic set debug on.
|
|
8074
|
+
// NOTE: transformRequestBody is a top-level function outside the plugin
|
|
8075
|
+
// closure where debugLog() lives, so we mirror debugLog's behaviour directly
|
|
8076
|
+
// (console.error + same prefix, gated on config.debug).
|
|
8077
|
+
if (config?.debug) {
|
|
8078
|
+
console.error("[opencode-anthropic-auth]", "transformRequestBody: incoming model", {
|
|
8079
|
+
model: parsed.model,
|
|
8080
|
+
adaptive: isAdaptiveThinkingModel(parsed.model || ""),
|
|
8081
|
+
thinkingType: parsed.thinking?.type,
|
|
8082
|
+
hasEffort: parsed.effort !== undefined || parsed.output_config?.effort !== undefined,
|
|
8083
|
+
speed: parsed.speed,
|
|
8084
|
+
});
|
|
8085
|
+
}
|
|
7795
8086
|
// Output cap: resolve max_tokens before any other body transforms
|
|
7796
8087
|
if (config?.output_cap?.enabled) {
|
|
7797
8088
|
parsed.max_tokens = resolveMaxTokens(parsed, config);
|
|
@@ -7863,10 +8154,19 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
7863
8154
|
// Anthropic API rejects temperature when thinking is enabled
|
|
7864
8155
|
delete parsed.temperature;
|
|
7865
8156
|
|
|
7866
|
-
// Claude Code v2.1.84: inject context_management body field when
|
|
7867
|
-
//
|
|
7868
|
-
//
|
|
7869
|
-
|
|
8157
|
+
// Claude Code v2.1.84: inject the context_management body field ONLY when the
|
|
8158
|
+
// context-management beta is actually enabled (token_economy.context_management,
|
|
8159
|
+
// the SAME flag that gates the beta in buildAnthropicBetaHeader). A top-level
|
|
8160
|
+
// context_management field WITHOUT the beta is rejected by the API with
|
|
8161
|
+
// "context_management: Extra inputs are not permitted". Keeping the field and
|
|
8162
|
+
// the anthropic-beta header in lockstep prevents that 400 — previously the
|
|
8163
|
+
// field was injected on every thinking turn regardless of the beta, which only
|
|
8164
|
+
// worked if the beta happened to be forced on via custom_betas/ANTHROPIC_BETAS.
|
|
8165
|
+
if (
|
|
8166
|
+
config?.token_economy?.context_management &&
|
|
8167
|
+
!/claude-3-/i.test(parsed.model || "") &&
|
|
8168
|
+
!parsed.context_management
|
|
8169
|
+
) {
|
|
7870
8170
|
parsed.context_management = {
|
|
7871
8171
|
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
|
7872
8172
|
};
|
|
@@ -8084,31 +8384,68 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
8084
8384
|
// our system prompt ttl=1h). Then add our own to the last user message
|
|
8085
8385
|
// (matching real CC behavior seen in proxy capture).
|
|
8086
8386
|
//
|
|
8087
|
-
// Role-scoped TTL: real CC's
|
|
8088
|
-
//
|
|
8089
|
-
//
|
|
8090
|
-
//
|
|
8091
|
-
//
|
|
8387
|
+
// Role-scoped TTL: real CC's REH(querySource) decides the `ttl:"1h"` field.
|
|
8388
|
+
// Decompiled from CC 2.1.154 (function REH, offset 225174828), precedence:
|
|
8389
|
+
// 1. FORCE_PROMPT_CACHING_5M => 5m (highest priority)
|
|
8390
|
+
// 2. ENABLE_PROMPT_CACHING_1H => 1h
|
|
8391
|
+
// 3. no tengu support / overage => 5m
|
|
8392
|
+
// 4. querySource ∈ allowlist [repl_main_thread*, sdk, auto_mode, ...] => 1h, else 5m
|
|
8393
|
+
// We map step 4 via classifyRequestRole: main (interactive/auto_mode thread)
|
|
8394
|
+
// → 1h; side-queries (title-gen, etc.) → 5m. The env vars are honored
|
|
8395
|
+
// exactly as CC does, giving the user a manual override + mimicry fidelity.
|
|
8092
8396
|
const configuredTtl = signature.cachePolicy?.ttl || "1h";
|
|
8093
8397
|
const roleScopedTtl = config?.token_economy?.role_scoped_cache_ttl !== false;
|
|
8094
8398
|
const isMainForCache = runtime?.requestRole === "main" || runtime?.requestRole == null;
|
|
8095
|
-
const
|
|
8399
|
+
const isSubagentForCache = runtime?.isSubagent === true;
|
|
8400
|
+
const ccTtl = resolveCacheTtl({
|
|
8401
|
+
configuredTtl,
|
|
8402
|
+
roleScopedTtl,
|
|
8403
|
+
isMainForCache,
|
|
8404
|
+
isSubagent: isSubagentForCache,
|
|
8405
|
+
env: process.env,
|
|
8406
|
+
});
|
|
8096
8407
|
if (Array.isArray(parsed.tools) && parsed.tools.length > 0) {
|
|
8097
8408
|
for (const tool of parsed.tools) {
|
|
8098
8409
|
if (tool.cache_control) delete tool.cache_control;
|
|
8099
8410
|
}
|
|
8100
|
-
//
|
|
8101
|
-
|
|
8411
|
+
// Adaptive breakpoint placement (opt-in, runtime-driven). Real CC always
|
|
8412
|
+
// pins the breakpoint on the last tool because CC controls a STABLE tool
|
|
8413
|
+
// array. opencode may reorder/add/remove tools between turns, which moves
|
|
8414
|
+
// the "last tool" and invalidates the cached prefix after it. When the
|
|
8415
|
+
// detector reports the tool boundary is thrashing while the system prompt
|
|
8416
|
+
// is stable, we SKIP the volatile last-tool breakpoint and let the stable
|
|
8417
|
+
// system-prompt breakpoint (added by buildSystemPromptBlocks) anchor the
|
|
8418
|
+
// cache. Falls back to exact CC behavior when no stability data exists.
|
|
8419
|
+
const placeToolBreakpoint = shouldPlaceToolBreakpoint(runtime?.cacheBoundaryStability);
|
|
8420
|
+
if (placeToolBreakpoint) {
|
|
8421
|
+
parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
|
|
8422
|
+
}
|
|
8102
8423
|
}
|
|
8103
8424
|
if (Array.isArray(parsed.messages)) {
|
|
8104
8425
|
for (const msg of parsed.messages) {
|
|
8105
8426
|
if (Array.isArray(msg.content)) {
|
|
8106
8427
|
for (const block of msg.content) {
|
|
8428
|
+
// CONTRACT GUARD: `thinking` / `redacted_thinking` blocks MUST be
|
|
8429
|
+
// round-tripped byte-identical to the model's original response
|
|
8430
|
+
// (Anthropic extended-thinking rules). ANY mutation — including
|
|
8431
|
+
// `delete block.cache_control` — triggers the 400 error
|
|
8432
|
+
// "thinking or redacted_thinking blocks in the latest assistant
|
|
8433
|
+
// message cannot be modified". `cache_control` is not even a valid
|
|
8434
|
+
// field on a thinking block, so we never strip nor add it here;
|
|
8435
|
+
// we leave the block exactly as received. This is required for
|
|
8436
|
+
// adaptive-thinking models (Opus 4.6/4.7/4.8, Sonnet 4.6) on
|
|
8437
|
+
// tool-continuation turns. See docs/mimese-http-header-system-prompt.md.
|
|
8438
|
+
if (!block || typeof block !== "object") continue;
|
|
8439
|
+
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
8440
|
+
continue;
|
|
8441
|
+
}
|
|
8107
8442
|
if (block.cache_control) delete block.cache_control;
|
|
8108
8443
|
}
|
|
8109
8444
|
}
|
|
8110
8445
|
}
|
|
8111
|
-
// Add cache_control to last user message (real CC does this)
|
|
8446
|
+
// Add cache_control to last user message (real CC does this).
|
|
8447
|
+
// (User messages never contain thinking blocks, so the guard above
|
|
8448
|
+
// does not affect this breakpoint.)
|
|
8112
8449
|
for (let i = parsed.messages.length - 1; i >= 0; i--) {
|
|
8113
8450
|
const msg = parsed.messages[i];
|
|
8114
8451
|
if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
|
|
@@ -8239,12 +8576,18 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
8239
8576
|
parsed.context_hint = { enabled: true };
|
|
8240
8577
|
}
|
|
8241
8578
|
|
|
8242
|
-
// Fast mode: inject speed parameter for
|
|
8243
|
-
//
|
|
8244
|
-
//
|
|
8245
|
-
//
|
|
8246
|
-
//
|
|
8247
|
-
//
|
|
8579
|
+
// Fast mode: inject speed parameter for fast-mode-eligible models.
|
|
8580
|
+
// Per Anthropic fast-mode docs, `speed: "fast"` (beta fast-mode-2026-02-01) is
|
|
8581
|
+
// supported on Opus 4.6, Opus 4.7, and Opus 4.8 (research preview). Opus 4.7
|
|
8582
|
+
// is the /fast default in real Claude Code v2.1.142+. Sonnet is NOT eligible.
|
|
8583
|
+
// NOTE: switching speed invalidates system + message prompt caches (per
|
|
8584
|
+
// Anthropic fast-mode docs), so only flip it deliberately.
|
|
8585
|
+
// When the selected account's fast pool is cooling down (a prior fast 429),
|
|
8586
|
+
// suppress speed:"fast" entirely so this turn runs at standard speed on the
|
|
8587
|
+
// same account rather than re-hitting the exhausted fast pool.
|
|
8588
|
+
const fastPoolAvailable = !signature.fastRateLimited;
|
|
8589
|
+
const isFastModeEligibleModel = (m) =>
|
|
8590
|
+
fastPoolAvailable && (isOpus46Model(m) || isOpus47Model(m) || isOpus48Model(m));
|
|
8248
8591
|
const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
|
|
8249
8592
|
let fastModeAutoApplied = false;
|
|
8250
8593
|
if (
|
|
@@ -8252,7 +8595,7 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
8252
8595
|
te.fast_mode_auto === true &&
|
|
8253
8596
|
isMainRole &&
|
|
8254
8597
|
parsed.model &&
|
|
8255
|
-
|
|
8598
|
+
isFastModeEligibleModel(parsed.model) &&
|
|
8256
8599
|
Array.isArray(parsed.messages) &&
|
|
8257
8600
|
parsed.messages.length >= 2
|
|
8258
8601
|
) {
|
|
@@ -8275,7 +8618,7 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
|
8275
8618
|
}
|
|
8276
8619
|
}
|
|
8277
8620
|
}
|
|
8278
|
-
if ((fastModeEnabled || fastModeAutoApplied) && parsed.model &&
|
|
8621
|
+
if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isFastModeEligibleModel(parsed.model)) {
|
|
8279
8622
|
parsed.speed = "fast";
|
|
8280
8623
|
}
|
|
8281
8624
|
|
|
@@ -8583,6 +8926,32 @@ function stripMcpPrefixFromParsedEvent(parsed) {
|
|
|
8583
8926
|
return modified;
|
|
8584
8927
|
}
|
|
8585
8928
|
|
|
8929
|
+
/**
|
|
8930
|
+
* Resolve the byte-stream idle timeout in milliseconds.
|
|
8931
|
+
*
|
|
8932
|
+
* Parity with Claude Code's `tengu_byte_stream_idle_timeout_ms`: a watchdog that
|
|
8933
|
+
* fires when the upstream SSE stream produces no bytes for N ms, so a stalled /
|
|
8934
|
+
* half-dead connection surfaces as a fast error instead of an indefinite hang.
|
|
8935
|
+
*
|
|
8936
|
+
* Resolution order: env `OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS` →
|
|
8937
|
+
* `config.streaming.idle_timeout_ms` → 0 (disabled). Disabled by default because
|
|
8938
|
+
* a too-aggressive value could abort a legitimate long generation; Anthropic SSE
|
|
8939
|
+
* emits periodic `ping` events, so a multi-minute silence is the safe signal.
|
|
8940
|
+
*
|
|
8941
|
+
* @param {any} cfg
|
|
8942
|
+
* @returns {number} timeout in ms, or 0 to disable
|
|
8943
|
+
*/
|
|
8944
|
+
function resolveStreamIdleTimeoutMs(cfg) {
|
|
8945
|
+
const envRaw = process.env.OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS;
|
|
8946
|
+
if (envRaw != null && envRaw !== "") {
|
|
8947
|
+
const n = Number(envRaw);
|
|
8948
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
8949
|
+
}
|
|
8950
|
+
const c = cfg?.streaming?.idle_timeout_ms;
|
|
8951
|
+
if (typeof c === "number" && Number.isFinite(c) && c >= 0) return Math.floor(c);
|
|
8952
|
+
return 0;
|
|
8953
|
+
}
|
|
8954
|
+
|
|
8586
8955
|
/**
|
|
8587
8956
|
* Wrap a response body stream to strip mcp_ prefix from tool names,
|
|
8588
8957
|
* extract token usage stats from SSE events, and detect mid-stream
|
|
@@ -8603,6 +8972,10 @@ function transformResponse(response, onUsage, onAccountError) {
|
|
|
8603
8972
|
const encoder = new TextEncoder();
|
|
8604
8973
|
const EMPTY_CHUNK = new Uint8Array();
|
|
8605
8974
|
|
|
8975
|
+
// Byte-stream idle-timeout watchdog (parity with CC tengu_byte_stream_idle_timeout_ms).
|
|
8976
|
+
// 0 = disabled (default). See resolveStreamIdleTimeoutMs.
|
|
8977
|
+
const idleTimeoutMs = resolveStreamIdleTimeoutMs(_pluginConfig);
|
|
8978
|
+
|
|
8606
8979
|
/** @type {UsageStats} */
|
|
8607
8980
|
const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
8608
8981
|
let sseBuffer = "";
|
|
@@ -8682,7 +9055,38 @@ function transformResponse(response, onUsage, onAccountError) {
|
|
|
8682
9055
|
|
|
8683
9056
|
const stream = new ReadableStream({
|
|
8684
9057
|
async pull(controller) {
|
|
8685
|
-
|
|
9058
|
+
let readResult;
|
|
9059
|
+
if (idleTimeoutMs > 0) {
|
|
9060
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
9061
|
+
let idleTimer;
|
|
9062
|
+
try {
|
|
9063
|
+
readResult = await Promise.race([
|
|
9064
|
+
reader.read(),
|
|
9065
|
+
new Promise((_resolve, reject) => {
|
|
9066
|
+
idleTimer = setTimeout(
|
|
9067
|
+
() => reject(new Error(`stream idle timeout: no bytes for ${idleTimeoutMs}ms`)),
|
|
9068
|
+
idleTimeoutMs,
|
|
9069
|
+
);
|
|
9070
|
+
}),
|
|
9071
|
+
]);
|
|
9072
|
+
} catch (err) {
|
|
9073
|
+
// Idle timeout (or read error): cancel the upstream reader and surface a
|
|
9074
|
+
// clear error so the consumer can retry instead of waiting on a dead
|
|
9075
|
+
// connection. Mirrors CC's stream-idle watchdog (no silent hang).
|
|
9076
|
+
try {
|
|
9077
|
+
await reader.cancel();
|
|
9078
|
+
} catch {
|
|
9079
|
+
// Reader may already be released; nothing to do.
|
|
9080
|
+
}
|
|
9081
|
+
controller.error(err instanceof Error ? err : new Error(String(err)));
|
|
9082
|
+
return;
|
|
9083
|
+
} finally {
|
|
9084
|
+
clearTimeout(idleTimer);
|
|
9085
|
+
}
|
|
9086
|
+
} else {
|
|
9087
|
+
readResult = await reader.read();
|
|
9088
|
+
}
|
|
9089
|
+
const { done, value } = readResult;
|
|
8686
9090
|
if (done) {
|
|
8687
9091
|
processSSEBuffer(true);
|
|
8688
9092
|
|
|
@@ -9034,6 +9438,8 @@ AnthropicAuthPlugin.__testing__ = {
|
|
|
9034
9438
|
buildSystemPromptBlocks,
|
|
9035
9439
|
stripMcpPrefixFromParsedEvent,
|
|
9036
9440
|
CORE_TOOL_NAMES,
|
|
9441
|
+
// exposed for subagent-detection tests (x-parent-session-id header extraction)
|
|
9442
|
+
getIncomingHeader,
|
|
9037
9443
|
// exposed for determinism regression tests (phase C1)
|
|
9038
9444
|
applyContextHintCompaction,
|
|
9039
9445
|
// exposed for session-dedupe regression tests (phase C3)
|
|
@@ -9069,4 +9475,19 @@ AnthropicAuthPlugin.__testing__ = {
|
|
|
9069
9475
|
},
|
|
9070
9476
|
};
|
|
9071
9477
|
|
|
9478
|
+
/**
|
|
9479
|
+
* Internal cache helpers exposed for unit testing only. Not part of the public
|
|
9480
|
+
* plugin API. Attached as a PROPERTY of the function for the SAME reason as
|
|
9481
|
+
* `__testing__` above: a bare `export const __cacheInternals = {...}` object
|
|
9482
|
+
* export breaks Opencode's plugin loader, which iterates `Object.values(mod)`
|
|
9483
|
+
* and throws "Plugin export is not a function" on ANY non-function export —
|
|
9484
|
+
* silently disabling the whole plugin (no slash command, no OAuth). Tests reach
|
|
9485
|
+
* these via `AnthropicAuthPlugin.__cacheInternals`.
|
|
9486
|
+
*/
|
|
9487
|
+
AnthropicAuthPlugin.__cacheInternals = {
|
|
9488
|
+
resolveCacheTtl,
|
|
9489
|
+
shouldPlaceToolBreakpoint,
|
|
9490
|
+
updateBoundaryStability,
|
|
9491
|
+
};
|
|
9492
|
+
|
|
9072
9493
|
export default AnthropicAuthPlugin;
|
package/lib/accounts.mjs
CHANGED
|
@@ -47,6 +47,16 @@ function hashTokenFragment(token) {
|
|
|
47
47
|
|
|
48
48
|
const MAX_ACCOUNTS = 10;
|
|
49
49
|
const RATE_LIMIT_KEY = "anthropic";
|
|
50
|
+
// Fast mode (speed:"fast") has a SEPARATE rate-limit pool from standard Opus
|
|
51
|
+
// per Anthropic docs. We track it under a distinct bucket so that exhausting the
|
|
52
|
+
// fast pool does NOT block standard-speed requests on the same account (it should
|
|
53
|
+
// only trigger a fallback to standard speed). The key lives in the same
|
|
54
|
+
// rateLimitResetTimes record, so no schema migration is needed — existing
|
|
55
|
+
// accounts simply won't have this key until they hit a fast-mode rate limit, and
|
|
56
|
+
// #clearExpiredRateLimits already iterates all keys generically.
|
|
57
|
+
const RATE_LIMIT_KEY_FAST = "anthropic:fast";
|
|
58
|
+
|
|
59
|
+
export { RATE_LIMIT_KEY, RATE_LIMIT_KEY_FAST };
|
|
50
60
|
|
|
51
61
|
/**
|
|
52
62
|
* @typedef {object} StatsDelta
|
|
@@ -351,16 +361,29 @@ export class AccountManager {
|
|
|
351
361
|
}
|
|
352
362
|
|
|
353
363
|
/**
|
|
354
|
-
* Check if an account is currently rate-limited.
|
|
364
|
+
* Check if an account is currently rate-limited for the given bucket.
|
|
365
|
+
* Defaults to the STANDARD bucket so that a fast-only rate limit never makes
|
|
366
|
+
* an account look unavailable for standard requests.
|
|
355
367
|
* @param {ManagedAccount} account
|
|
368
|
+
* @param {string} [bucket] - Rate-limit bucket key (default: standard).
|
|
356
369
|
* @returns {boolean}
|
|
357
370
|
*/
|
|
358
|
-
#isRateLimited(account) {
|
|
371
|
+
#isRateLimited(account, bucket = RATE_LIMIT_KEY) {
|
|
359
372
|
this.#clearExpiredRateLimits(account);
|
|
360
|
-
const resetTime = account.rateLimitResetTimes[
|
|
373
|
+
const resetTime = account.rateLimitResetTimes[bucket];
|
|
361
374
|
return resetTime !== undefined && Date.now() < resetTime;
|
|
362
375
|
}
|
|
363
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Public check: is this account rate-limited for the fast-mode pool?
|
|
379
|
+
* Used to decide whether to inject speed:"fast" on a request.
|
|
380
|
+
* @param {ManagedAccount} account
|
|
381
|
+
* @returns {boolean}
|
|
382
|
+
*/
|
|
383
|
+
isFastRateLimited(account) {
|
|
384
|
+
return this.#isRateLimited(account, RATE_LIMIT_KEY_FAST);
|
|
385
|
+
}
|
|
386
|
+
|
|
364
387
|
/**
|
|
365
388
|
* Select the best account for the current request.
|
|
366
389
|
* @param {Set<number>} [excludedIndices] - Temporary per-request exclusions
|
|
@@ -408,27 +431,43 @@ export class AccountManager {
|
|
|
408
431
|
|
|
409
432
|
/**
|
|
410
433
|
* Mark an account as rate-limited.
|
|
434
|
+
*
|
|
435
|
+
* When `bucket` is the FAST pool (RATE_LIMIT_KEY_FAST), this is a soft marker:
|
|
436
|
+
* it only cools down the fast pool and does NOT increment consecutiveFailures
|
|
437
|
+
* or apply a health penalty, because the standard pool is still fully usable
|
|
438
|
+
* (the request can simply fall back to standard speed). Marking the whole
|
|
439
|
+
* account unhealthy for a fast-only limit would defeat Anthropic's separate
|
|
440
|
+
* fast pool and waste standard quota.
|
|
411
441
|
* @param {ManagedAccount} account
|
|
412
442
|
* @param {RateLimitReason} reason
|
|
413
443
|
* @param {number | null} [retryAfterMs]
|
|
444
|
+
* @param {string} [bucket] - Rate-limit bucket key (default: standard).
|
|
414
445
|
* @returns {number} The backoff duration in ms
|
|
415
446
|
*/
|
|
416
|
-
markRateLimited(account, reason, retryAfterMs) {
|
|
447
|
+
markRateLimited(account, reason, retryAfterMs, bucket = RATE_LIMIT_KEY) {
|
|
417
448
|
const now = Date.now();
|
|
449
|
+
const isFastBucket = bucket === RATE_LIMIT_KEY_FAST;
|
|
418
450
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
account.
|
|
422
|
-
|
|
451
|
+
if (!isFastBucket) {
|
|
452
|
+
// Reset consecutive failures if TTL has expired
|
|
453
|
+
if (account.lastFailureTime !== null && now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000) {
|
|
454
|
+
account.consecutiveFailures = 0;
|
|
455
|
+
}
|
|
423
456
|
|
|
424
|
-
|
|
425
|
-
|
|
457
|
+
account.consecutiveFailures += 1;
|
|
458
|
+
account.lastFailureTime = now;
|
|
459
|
+
}
|
|
426
460
|
|
|
427
|
-
|
|
461
|
+
// Fast bucket does not track consecutiveFailures, so use index 0 to avoid a
|
|
462
|
+
// negative array index in calculateBackoffMs (QUOTA_EXHAUSTED path).
|
|
463
|
+
const failureIndex = isFastBucket ? 0 : account.consecutiveFailures - 1;
|
|
464
|
+
const backoffMs = calculateBackoffMs(reason, failureIndex, retryAfterMs);
|
|
428
465
|
|
|
429
|
-
account.rateLimitResetTimes[
|
|
466
|
+
account.rateLimitResetTimes[bucket] = now + backoffMs;
|
|
430
467
|
|
|
431
|
-
|
|
468
|
+
if (!isFastBucket) {
|
|
469
|
+
this.#healthTracker.recordRateLimit(account.index);
|
|
470
|
+
}
|
|
432
471
|
|
|
433
472
|
this.requestSaveToDisk();
|
|
434
473
|
|
package/lib/accounts.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { AccountManager } from "./accounts.mjs";
|
|
2
|
+
import { AccountManager, RATE_LIMIT_KEY_FAST } from "./accounts.mjs";
|
|
3
3
|
import { DEFAULT_CONFIG } from "./config.mjs";
|
|
4
4
|
|
|
5
5
|
// Mock storage module — pass through pure helpers, mock I/O
|
|
@@ -331,6 +331,41 @@ describe("AccountManager rate limiting", () => {
|
|
|
331
331
|
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
332
332
|
expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
// ── Fast-mode pool isolation (Anthropic: fast has a SEPARATE rate-limit pool) ──
|
|
336
|
+
it("fast-bucket rate limit does NOT block standard requests on the same account", () => {
|
|
337
|
+
const account = manager.getCurrentAccount();
|
|
338
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
|
|
339
|
+
// Fast pool is limited...
|
|
340
|
+
expect(manager.isFastRateLimited(account)).toBe(true);
|
|
341
|
+
// ...but the account is still selectable for standard requests (standard
|
|
342
|
+
// bucket untouched, so getCurrentAccount still returns it).
|
|
343
|
+
expect(manager.getCurrentAccount()).not.toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("fast-bucket rate limit does NOT increment consecutiveFailures or set lastFailureTime", () => {
|
|
347
|
+
const account = manager.getCurrentAccount();
|
|
348
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
|
|
349
|
+
expect(account.consecutiveFailures).toBe(0);
|
|
350
|
+
expect(account.lastFailureTime).toBeNull();
|
|
351
|
+
expect(manager.isFastRateLimited(account)).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("standard rate limit does NOT mark the fast bucket as limited", () => {
|
|
355
|
+
const account = manager.getCurrentAccount();
|
|
356
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
357
|
+
expect(manager.isFastRateLimited(account)).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("fast-bucket backoff returns a positive duration and expires independently", () => {
|
|
361
|
+
const account = manager.getCurrentAccount();
|
|
362
|
+
const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
|
|
363
|
+
expect(backoffMs).toBeGreaterThan(0);
|
|
364
|
+
expect(manager.isFastRateLimited(account)).toBe(true);
|
|
365
|
+
// After the backoff expires, the fast pool is available again.
|
|
366
|
+
vi.advanceTimersByTime(backoffMs + 1000);
|
|
367
|
+
expect(manager.isFastRateLimited(account)).toBe(false);
|
|
368
|
+
});
|
|
334
369
|
});
|
|
335
370
|
|
|
336
371
|
// ---------------------------------------------------------------------------
|
package/lib/config.mjs
CHANGED
|
@@ -97,7 +97,7 @@ import { randomBytes } from "node:crypto";
|
|
|
97
97
|
* @property {{ enabled: boolean, default_max_tokens: number, escalated_max_tokens: number }} output_cap
|
|
98
98
|
* @property {{ enabled: boolean, timeout_ms: number }} preconnect
|
|
99
99
|
* @property {{ enabled: boolean, safety_margin: number }} overflow_recovery
|
|
100
|
-
* @property {{ enabled: boolean, alert_threshold: number }} cache_break_detection
|
|
100
|
+
* @property {{ enabled: boolean, alert_threshold: number, adaptive_breakpoint: boolean }} cache_break_detection
|
|
101
101
|
* @property {{ enabled: boolean, background_max_service_retries: number, background_max_should_retries: number }} request_classification
|
|
102
102
|
* @property {{ enabled: boolean, default: number, completion_threshold: number }} token_budget
|
|
103
103
|
* @property {{ enabled: boolean, threshold_percent: number }} microcompact
|
|
@@ -191,15 +191,25 @@ export const DEFAULT_CONFIG = {
|
|
|
191
191
|
* (no point suggesting reset at the start of a session). */
|
|
192
192
|
min_turns_before_suggest: 3,
|
|
193
193
|
},
|
|
194
|
+
/** SSE response streaming behavior. */
|
|
195
|
+
streaming: {
|
|
196
|
+
/** Byte-stream idle-timeout watchdog (ms). Parity with Claude Code's
|
|
197
|
+
* `tengu_byte_stream_idle_timeout_ms`: if the upstream produces no bytes for
|
|
198
|
+
* this long, the reader is cancelled and the stream errors instead of hanging.
|
|
199
|
+
* 0 = disabled (default — Anthropic SSE sends periodic pings, so only set a
|
|
200
|
+
* multi-minute value to catch genuinely dead connections). Env override:
|
|
201
|
+
* `OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS`. */
|
|
202
|
+
idle_timeout_ms: 0,
|
|
203
|
+
},
|
|
194
204
|
/** Token economy optimizations. */
|
|
195
205
|
token_economy: {
|
|
196
206
|
/** [DEPRECATED] token-efficient-tools-2026-03-28 was removed in CC v2.1.90.
|
|
197
207
|
* This flag is retained for config backward compatibility but has no effect. */
|
|
198
208
|
token_efficient_tools: false,
|
|
199
209
|
/** Enable redact-thinking-2026-02-12 beta (suppresses thinking summaries
|
|
200
|
-
* server-side for lower bandwidth).
|
|
201
|
-
*
|
|
202
|
-
redact_thinking:
|
|
210
|
+
* server-side for lower bandwidth). On by default for non-Claude-3 models
|
|
211
|
+
* (CC v2.1.150+). Opt out via `/anthropic set redact-thinking off`. */
|
|
212
|
+
redact_thinking: true,
|
|
203
213
|
/** Enable context-hint-2026-04-09 beta (CC v2.1.110+). When on, the server
|
|
204
214
|
* MAY reject 422/424 and trigger client-side compaction retries.
|
|
205
215
|
* Default ON (Phase C2). Server-side gating + contextHintState.disabled
|
|
@@ -276,15 +286,18 @@ export const DEFAULT_CONFIG = {
|
|
|
276
286
|
* `ttl:"1h"` on an allowlist of query sources (`repl_main_thread*`, `sdk`,
|
|
277
287
|
* `auto_mode`). Everything else falls back to the 5m tier, which is
|
|
278
288
|
* cheaper to write. Matches by classifying request role: main → 1h (or
|
|
279
|
-
* whatever signature.cachePolicy.ttl is configured as), else → 5m.
|
|
280
|
-
|
|
289
|
+
* whatever signature.cachePolicy.ttl is configured as), else → 5m.
|
|
290
|
+
* Default ON: this MATCHES real CC behaviour (main thread 1h, side-queries
|
|
291
|
+
* 5m) and only makes non-main cache writes cheaper — no fingerprint risk. */
|
|
292
|
+
role_scoped_cache_ttl: true,
|
|
281
293
|
|
|
282
294
|
/** Lean system prompt for non-main requests. For title-gen / small /
|
|
283
295
|
* subagent-shaped requests, strip billing identity + CC identity
|
|
284
|
-
* injection. Saves ~1-2kB per request.
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
|
|
296
|
+
* injection. Saves ~1-2kB per request. Default ON: only affects non-main
|
|
297
|
+
* (title-gen / subagent) requests where the CC identity/billing block is
|
|
298
|
+
* not load-bearing for the main-thread fingerprint; the main interactive
|
|
299
|
+
* thread is untouched. */
|
|
300
|
+
lean_system_non_main: true,
|
|
288
301
|
|
|
289
302
|
/** Simple system prompt mode (real CC v2.1.133+ `tengu_vellum_lantern`
|
|
290
303
|
* equivalent). When enabled AND the request model is eligible
|
|
@@ -294,8 +307,11 @@ export const DEFAULT_CONFIG = {
|
|
|
294
307
|
* Conservative implementation: gates ONLY anti-verbosity. Identity
|
|
295
308
|
* preamble, billing block, user instructions, and `<system-reminder>`
|
|
296
309
|
* blocks are all preserved so the CC fingerprint match still holds.
|
|
297
|
-
* Saves ~600-1500 tokens per request on eligible models.
|
|
298
|
-
|
|
310
|
+
* Saves ~600-1500 tokens per request on eligible models. Default ON:
|
|
311
|
+
* gates ONLY the anti-verbosity boilerplate (which real CC v2.1.133+ also
|
|
312
|
+
* drops for eligible Opus 4.x via tengu_vellum_lantern); identity, billing
|
|
313
|
+
* and system-reminders are preserved, so the fingerprint match holds. */
|
|
314
|
+
simple_system_prompt: true,
|
|
299
315
|
|
|
300
316
|
// NOTE: identical-tool-call short-circuit was considered but requires SSE
|
|
301
317
|
// response-stream rewriting that breaks normal tool-execution semantics.
|
|
@@ -361,6 +377,17 @@ export const DEFAULT_CONFIG = {
|
|
|
361
377
|
cache_break_detection: {
|
|
362
378
|
enabled: true,
|
|
363
379
|
alert_threshold: 2_000,
|
|
380
|
+
/**
|
|
381
|
+
* Adaptive breakpoint placement (opt-in). When true, the prompt-cache
|
|
382
|
+
* breakpoint is anchored on the most-stable boundary (e.g. the system
|
|
383
|
+
* prompt) instead of always on the last tool. This is a DELIBERATE
|
|
384
|
+
* divergence from Claude Code, useful for hosts (like opencode) whose tool
|
|
385
|
+
* array order/count is less stable than CC's. Default true (opencode's tool
|
|
386
|
+
* presentation is less stable than CC's, so adaptive anchoring improves cache
|
|
387
|
+
* hit rate). Set false to force exact CC behavior. Safe either way: with no
|
|
388
|
+
* stability baseline (first turns) it falls back to CC's last-tool breakpoint.
|
|
389
|
+
*/
|
|
390
|
+
adaptive_breakpoint: true,
|
|
364
391
|
},
|
|
365
392
|
/** Request classification: reduced retry budget for background requests. */
|
|
366
393
|
request_classification: {
|
|
@@ -885,6 +912,10 @@ function validateConfig(raw) {
|
|
|
885
912
|
1_000_000,
|
|
886
913
|
DEFAULT_CONFIG.cache_break_detection.alert_threshold,
|
|
887
914
|
),
|
|
915
|
+
adaptive_breakpoint:
|
|
916
|
+
typeof cbd.adaptive_breakpoint === "boolean"
|
|
917
|
+
? cbd.adaptive_breakpoint
|
|
918
|
+
: DEFAULT_CONFIG.cache_break_detection.adaptive_breakpoint,
|
|
888
919
|
};
|
|
889
920
|
}
|
|
890
921
|
|
package/lib/request-headers.mjs
CHANGED
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
// Version / build / SDK constants
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
|
-
export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.
|
|
19
|
+
export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.159";
|
|
20
20
|
export const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
|
|
21
|
-
|
|
21
|
+
// Real build markers extracted from the 2.1.159 linux-x64 native binary
|
|
22
|
+
// (Bun-embedded JS): `BUILD_TIME:"2026-05-31T16:22:50Z"`,
|
|
23
|
+
// `GIT_SHA:"dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b"`.
|
|
24
|
+
// (Prior 2.1.154 win32-x64: BUILD_TIME 2026-05-28T12:27:24Z,
|
|
25
|
+
// GIT_SHA b84d2da9ada13121515426fc644786a303e9ac53.)
|
|
26
|
+
export const CLAUDE_CODE_BUILD_TIME = "2026-05-31T16:22:50Z";
|
|
27
|
+
export const CLAUDE_CODE_GIT_SHA = "dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b";
|
|
22
28
|
|
|
23
29
|
// The @anthropic-ai/sdk version bundled with Claude Code.
|
|
24
30
|
// This is distinct from the CLI version and goes in X-Stainless-Package-Version.
|
|
@@ -28,10 +34,27 @@ export const CLAUDE_CODE_BUILD_TIME = "2026-05-15T17:39:39Z";
|
|
|
28
34
|
// no SDK version bump; mid-conversation-system-2026-04-07 registered in 2.1.143 but not
|
|
29
35
|
// auto-emitted; two new conditional x- headers x-claude-code-agent-id /
|
|
30
36
|
// x-claude-code-parent-agent-id are only emitted in subagent dispatch contexts).
|
|
31
|
-
export const ANTHROPIC_SDK_VERSION = "0.
|
|
37
|
+
export const ANTHROPIC_SDK_VERSION = "0.94.0";
|
|
32
38
|
|
|
33
39
|
// Map of CLI version to bundled SDK version (update when CLI version changes)
|
|
34
40
|
export const CLI_TO_SDK_VERSION = new Map([
|
|
41
|
+
// 2.1.155-2.1.159: only wire change is the revival of
|
|
42
|
+
// summarize-connector-text-2026-03-13 (label narration_summaries), gated by
|
|
43
|
+
// GrowthBook pewter_owl_header (default-off) — not always-on. SDK still 0.94.0
|
|
44
|
+
// (binary-verified: x-stainless-package-version NQ="0.94.0" in 2.1.159).
|
|
45
|
+
["2.1.159", "0.94.0"],
|
|
46
|
+
["2.1.158", "0.94.0"],
|
|
47
|
+
["2.1.157", "0.94.0"],
|
|
48
|
+
["2.1.156", "0.94.0"],
|
|
49
|
+
["2.1.155", "0.94.0"],
|
|
50
|
+
// 2.1.151-2.1.154: Opus 4.8 launch (2026-05-28). npm now ships a native
|
|
51
|
+
// binary (no JS bundle), so the bundled SDK version can't be string-extracted;
|
|
52
|
+
// no SDK bump was observed across 2.1.150->2.1.154, so we carry 0.94.0 forward.
|
|
53
|
+
["2.1.154", "0.94.0"],
|
|
54
|
+
["2.1.153", "0.94.0"],
|
|
55
|
+
["2.1.152", "0.94.0"],
|
|
56
|
+
["2.1.151", "0.94.0"],
|
|
57
|
+
["2.1.150", "0.94.0"],
|
|
35
58
|
["2.1.143", "0.81.0"],
|
|
36
59
|
["2.1.142", "0.81.0"],
|
|
37
60
|
["2.1.141", "0.81.0"],
|
|
@@ -113,6 +136,9 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
|
|
|
113
136
|
"advisor-tool-2026-03-01",
|
|
114
137
|
"afk-mode-2026-01-31",
|
|
115
138
|
"cache-diagnosis-2026-04-07",
|
|
139
|
+
// CCR bring-your-own-cloud beta. Registered in the 2.1.154 binary's _W() table
|
|
140
|
+
// but not emitted on /v1/messages; kept here for the disable-experimental guard.
|
|
141
|
+
"ccr-byoc-2025-07-29",
|
|
116
142
|
"code-execution-2025-08-25",
|
|
117
143
|
"compact-2026-01-12",
|
|
118
144
|
"context-1m-2025-08-07",
|
|
@@ -123,6 +149,9 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
|
|
|
123
149
|
"fast-mode-2026-02-01",
|
|
124
150
|
"files-api-2025-04-14",
|
|
125
151
|
"interleaved-thinking-2025-05-14",
|
|
152
|
+
// Registered in the 2.1.154 binary's _W() table (mcp_servers label). Plugin
|
|
153
|
+
// proxies MCP tool calls inline and does not emit this on chat completions.
|
|
154
|
+
"mcp-servers-2025-12-04",
|
|
126
155
|
// Registered in CC v2.1.143 but NOT in real CC's always-on emission set
|
|
127
156
|
// (master registry only, not in the always-on subset). Likely GrowthBook-gated
|
|
128
157
|
// pending server rollout. Paired telemetry: tengu_mid_conv_system_fallback_retry.
|
|
@@ -130,6 +159,11 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
|
|
|
130
159
|
"prompt-caching-scope-2026-01-05",
|
|
131
160
|
"redact-thinking-2026-02-12",
|
|
132
161
|
"structured-outputs-2025-12-15",
|
|
162
|
+
// Revived in CC 2.1.159 as registry label `narration_summaries` (it was a dead
|
|
163
|
+
// slot from v2.1.90-2.1.154). Real CC emits it only when GrowthBook flag
|
|
164
|
+
// `pewter_owl_header` is on (default-off), first-party, and NOT in fast-mode.
|
|
165
|
+
// Listed here for the disable-guard + manual opt-in ONLY; never emitted always-on.
|
|
166
|
+
"summarize-connector-text-2026-03-13",
|
|
133
167
|
"task-budgets-2026-03-13",
|
|
134
168
|
"tool-search-tool-2025-10-19",
|
|
135
169
|
// SDK admin route beta (used for /v1/user_profiles* endpoints). Registered for
|
|
@@ -159,6 +193,21 @@ export const BETA_SHORTCUTS = new Map([
|
|
|
159
193
|
["redact-thinking", "redact-thinking-2026-02-12"],
|
|
160
194
|
["mid-conv-system", "mid-conversation-system-2026-04-07"],
|
|
161
195
|
["mid-system", "mid-conversation-system-2026-04-07"],
|
|
196
|
+
["context-management", "context-management-2025-06-27"],
|
|
197
|
+
["structured-outputs", "structured-outputs-2025-12-15"],
|
|
198
|
+
["web-search", "web-search-2025-03-05"],
|
|
199
|
+
["advanced-tool-use", "advanced-tool-use-2025-11-20"],
|
|
200
|
+
["tool-search-tool", "tool-search-tool-2025-10-19"],
|
|
201
|
+
["effort", "effort-2025-11-24"],
|
|
202
|
+
["prompt-caching-scope", "prompt-caching-scope-2026-01-05"],
|
|
203
|
+
["thinking-token-count", "thinking-token-count-2026-05-13"],
|
|
204
|
+
["afk-mode", "afk-mode-2026-01-31"],
|
|
205
|
+
["advisor-tool", "advisor-tool-2026-03-01"],
|
|
206
|
+
["mcp-servers", "mcp-servers-2025-12-04"],
|
|
207
|
+
["ccr-byoc", "ccr-byoc-2025-07-29"],
|
|
208
|
+
["connector-text", "summarize-connector-text-2026-03-13"],
|
|
209
|
+
["narration-summaries", "summarize-connector-text-2026-03-13"],
|
|
210
|
+
["summarize-connector-text", "summarize-connector-text-2026-03-13"],
|
|
162
211
|
]);
|
|
163
212
|
|
|
164
213
|
// ---------------------------------------------------------------------------
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
} from "./request-headers.mjs";
|
|
14
14
|
|
|
15
15
|
describe("request-headers constants", () => {
|
|
16
|
-
it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.
|
|
17
|
-
expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.
|
|
16
|
+
it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.159", () => {
|
|
17
|
+
expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.159");
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it("ANTHROPIC_SDK_VERSION matches expected value", () => {
|