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