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