opencode-anthropic-fix 0.1.32 → 0.2.0

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.
Files changed (37) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.test.mjs +298 -1
  5. package/lib/diagnose.mjs +184 -0
  6. package/lib/diagnose.test.mjs +122 -0
  7. package/lib/env.mjs +19 -0
  8. package/lib/env.test.mjs +25 -0
  9. package/lib/mimicry/cache.mjs +62 -0
  10. package/lib/mimicry/cache.test.mjs +112 -0
  11. package/lib/mimicry/headers.mjs +530 -0
  12. package/lib/mimicry/headers.test.mjs +50 -0
  13. package/lib/mimicry/models.mjs +146 -0
  14. package/lib/mimicry/models.test.mjs +132 -0
  15. package/lib/mimicry/request-body.mjs +684 -0
  16. package/lib/mimicry/request-body.test.mjs +34 -0
  17. package/lib/mimicry/request-helpers.mjs +243 -0
  18. package/lib/mimicry/request-helpers.test.mjs +155 -0
  19. package/lib/mimicry/response-stream.mjs +429 -0
  20. package/lib/mimicry/response-stream.test.mjs +127 -0
  21. package/lib/mimicry/system-prompt.mjs +638 -0
  22. package/lib/mimicry/system-prompt.test.mjs +73 -0
  23. package/lib/redact.mjs +147 -0
  24. package/lib/redact.test.mjs +154 -0
  25. package/lib/refresh-lock.test.mjs +52 -0
  26. package/lib/retry/overload-loop.mjs +83 -0
  27. package/lib/retry/overload-loop.test.mjs +107 -0
  28. package/lib/session-metrics.mjs +75 -0
  29. package/lib/session-metrics.test.mjs +67 -0
  30. package/lib/storage.test.mjs +82 -0
  31. package/lib/token-economy/microcompact.mjs +35 -0
  32. package/lib/token-economy/microcompact.test.mjs +28 -0
  33. package/lib/token-economy/transforms.mjs +637 -0
  34. package/lib/token-economy/transforms.test.mjs +192 -0
  35. package/lib/tuning.mjs +70 -0
  36. package/lib/tuning.test.mjs +89 -0
  37. package/package.json +4 -1
package/index.mjs CHANGED
@@ -37,6 +37,62 @@ import {
37
37
  import { callHaiku } from "./lib/haiku-call.mjs";
38
38
  import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
39
39
  import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
40
+ import {
41
+ estimatePromptTokensFromParsed,
42
+ applySessionToolResultDedupe,
43
+ maybeApplySessionToolResultDedupe,
44
+ applyContextHintCompaction,
45
+ } from "./lib/token-economy/transforms.mjs";
46
+ import { shouldMicrocompact, buildMicrocompactBetas } from "./lib/token-economy/microcompact.mjs";
47
+ import { redactSecrets, redactString } from "./lib/redact.mjs";
48
+ import {
49
+ createTransformedSSEStream,
50
+ resolveStreamIdleTimeoutMs,
51
+ stripMcpPrefixFromParsedEvent,
52
+ } from "./lib/mimicry/response-stream.mjs";
53
+ import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
54
+ import { isFalsyEnv, isTruthyEnv } from "./lib/env.mjs";
55
+ import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
56
+ import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
57
+ import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
58
+ import { SERVICE_WIDE_MAX_RETRIES, CONSECUTIVE_529_FALLBACK_THRESHOLD } from "./lib/tuning.mjs";
59
+ import {
60
+ computeServiceRetrySleepMs,
61
+ selectFallbackModel,
62
+ shouldServiceRetry,
63
+ isTransientRateLimit,
64
+ } from "./lib/retry/overload-loop.mjs";
65
+ import {
66
+ buildRequestHeaders,
67
+ buildAnthropicBetaHeader,
68
+ parseRequestBodyMetadata,
69
+ detectProvider,
70
+ extractFileIds,
71
+ } from "./lib/mimicry/headers.mjs";
72
+ import {
73
+ buildSystemPromptBlocks,
74
+ compactSystemText,
75
+ compactToolDescription,
76
+ dedupeSystemBlocks,
77
+ getCachedCCPrompt,
78
+ isTitleGeneratorSystemBlocks,
79
+ normalizeSystemTextBlocks,
80
+ resetCachedCCPrompt,
81
+ sanitizeSystemText,
82
+ CLAUDE_CODE_IDENTITY_STRING,
83
+ SUBAGENT_CC_ANCHOR,
84
+ } from "./lib/mimicry/system-prompt.mjs";
85
+ import {
86
+ CLAUDE_3_MODEL_RE,
87
+ hasOneMillionContext,
88
+ isEligibleFor1MContext,
89
+ isOpus46Model,
90
+ isOpus47Model,
91
+ isOpus48Model,
92
+ isSonnet46Model,
93
+ } from "./lib/mimicry/models.mjs";
94
+
95
+ export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/models.mjs";
40
96
 
41
97
  // Max times a single logical request may fall back from fast->standard speed on
42
98
  // the same account before giving up the fast attempt entirely. 1 is enough: one
@@ -2658,7 +2714,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2658
2714
  const maxServiceRetries =
2659
2715
  requestClass === "background"
2660
2716
  ? (config.request_classification?.background_max_service_retries ?? 0)
2661
- : 2;
2717
+ : SERVICE_WIDE_MAX_RETRIES;
2662
2718
  const maxShouldRetries =
2663
2719
  requestClass === "background" ? (config.request_classification?.background_max_should_retries ?? 1) : 3;
2664
2720
  let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
@@ -3095,12 +3151,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3095
3151
  // mutates system[0] each turn, invalidating the prompt cache.
3096
3152
  const finalBody = body;
3097
3153
 
3154
+ const correlationId = createDebugCorrelationId();
3155
+
3098
3156
  // Opt-in: dump the OUTGOING body (post-cch) so diagnostics reflect
3099
3157
  // exactly what went on the wire. Previously dumped `body` which
3100
3158
  // still had the cch=00000 placeholder — that confused debugging.
3101
3159
  // Rotates at 10 files to cap disk usage. Files live under
3102
3160
  // ~/.opencode/opencode-anthropic-fix/request-dumps/.
3103
- if (config.token_economy?.debug_dump_bodies === true && typeof finalBody === "string") {
3161
+ if (isDebugSinkEnabled(config, "body") && typeof finalBody === "string") {
3104
3162
  try {
3105
3163
  const fs = await import("node:fs");
3106
3164
  const path = await import("node:path");
@@ -3116,7 +3174,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3116
3174
  fs.unlinkSync(path.join(dir, existing.shift()));
3117
3175
  }
3118
3176
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
3119
- fs.writeFileSync(path.join(dir, `req-${ts}.json`), finalBody);
3177
+ const dump = createDebugRequestDump(correlationId, ts, finalBody);
3178
+ fs.writeFileSync(path.join(dir, dump.filename), dump.content);
3120
3179
  } catch {
3121
3180
  // Disk full, permissions, whatever — never block the request.
3122
3181
  }
@@ -3125,6 +3184,23 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3125
3184
  // Execute the request
3126
3185
  let response;
3127
3186
  try {
3187
+ if (isDebugSinkEnabled(config, "headers")) {
3188
+ try {
3189
+ const { existsSync, renameSync, statSync, unlinkSync, writeFileSync } = await import("node:fs");
3190
+ const { join } = await import("node:path");
3191
+ const debugFile = join(getConfigDir(), "debug-headers.log");
3192
+ const rotatedFile = `${debugFile}.1`;
3193
+ if (existsSync(debugFile) && statSync(debugFile).size > 2 * 1024 * 1024) {
3194
+ if (existsSync(rotatedFile)) unlinkSync(rotatedFile);
3195
+ renameSync(debugFile, rotatedFile);
3196
+ }
3197
+ const ts = new Date().toISOString();
3198
+ const entry = createDebugOutgoingHeadersEntry(correlationId, ts, requestHeaders);
3199
+ writeFileSync(debugFile, entry, { flag: "a" });
3200
+ } catch (e) {
3201
+ debugLog("failed to write outgoing request headers to debug-headers.log", e);
3202
+ }
3203
+ }
3128
3204
  response = await fetch(requestInput, {
3129
3205
  ...requestInit,
3130
3206
  body: finalBody,
@@ -3183,7 +3259,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3183
3259
 
3184
3260
  // Debug: log all response headers to file for diagnosis
3185
3261
  // Placed BEFORE the response.ok guard so we capture headers on ALL responses
3186
- if (config.debug) {
3262
+ if (isDebugSinkEnabled(config, "headers")) {
3187
3263
  const rlHeaders = {};
3188
3264
  const allHeaders = {};
3189
3265
  response.headers.forEach((value, key) => {
@@ -3192,6 +3268,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3192
3268
  rlHeaders[key] = value;
3193
3269
  }
3194
3270
  });
3271
+ const redactedRlHeaders = redactSecrets(rlHeaders);
3272
+ const redactedAllHeaders = redactSecrets(allHeaders);
3195
3273
  debugLog(
3196
3274
  "response status:",
3197
3275
  response.status,
@@ -3202,19 +3280,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3202
3280
  "accountManager:",
3203
3281
  !!accountManager,
3204
3282
  );
3205
- debugLog("ALL response headers:", allHeaders);
3283
+ debugLog("ALL response headers:", redactedAllHeaders);
3206
3284
  // Write to file for reliable access
3207
3285
  try {
3208
- const { writeFileSync } = await import("node:fs");
3286
+ const { existsSync, renameSync, statSync, unlinkSync, writeFileSync } = await import("node:fs");
3209
3287
  const { join } = await import("node:path");
3210
3288
  const debugFile = join(getConfigDir(), "debug-headers.log");
3289
+ const rotatedFile = `${debugFile}.1`;
3290
+ if (existsSync(debugFile) && statSync(debugFile).size > 2 * 1024 * 1024) {
3291
+ if (existsSync(rotatedFile)) unlinkSync(rotatedFile);
3292
+ renameSync(debugFile, rotatedFile);
3293
+ }
3211
3294
  const ts = new Date().toISOString();
3212
- const entry = [
3213
- `\n=== ${ts} | status=${response.status} ok=${response.ok} account=${!!account} mgr=${!!accountManager} ===`,
3214
- `Rate-limit headers: ${JSON.stringify(rlHeaders, null, 2)}`,
3215
- `All headers: ${JSON.stringify(allHeaders, null, 2)}`,
3216
- "",
3217
- ].join("\n");
3295
+ const entry = createDebugResponseHeadersEntry(correlationId, ts, response, {
3296
+ account: !!account,
3297
+ accountManager: !!accountManager,
3298
+ rateLimitHeaders: redactedRlHeaders,
3299
+ allHeaders: redactedAllHeaders,
3300
+ });
3218
3301
  writeFileSync(debugFile, entry, { flag: "a" });
3219
3302
  } catch (e) {
3220
3303
  debugLog("failed to write debug-headers.log", e);
@@ -3722,7 +3805,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3722
3805
  status: response.status,
3723
3806
  errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
3724
3807
  });
3725
- return transformResponse(response);
3808
+ return transformResponse(response, undefined, undefined, correlationId);
3726
3809
  }
3727
3810
 
3728
3811
  const accountSpecific = isAccountSpecificError(response.status, errorBody);
@@ -3754,13 +3837,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3754
3837
  // Transient 429: short retry-after (<=10s) is a burst throttle.
3755
3838
  // Retry on the SAME account instead of rotating — avoids wasting
3756
3839
  // the account pool on momentary rate spikes.
3757
- if (
3758
- response.status === 429 &&
3759
- reason === "RATE_LIMIT_EXCEEDED" &&
3760
- retryAfterMs != null &&
3761
- retryAfterMs > 0 &&
3762
- retryAfterMs <= TRANSIENT_RETRY_THRESHOLD_MS
3763
- ) {
3840
+ if (isTransientRateLimit(response.status, reason, retryAfterMs, TRANSIENT_RETRY_THRESHOLD_MS)) {
3764
3841
  debugLog("transient 429: sleeping before same-account retry", {
3765
3842
  retryAfterMs,
3766
3843
  account: account.email || `Account ${account.index + 1}`,
@@ -3851,26 +3928,19 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3851
3928
 
3852
3929
  // 529 (overloaded) and 503 (service unavailable) — brief sleep-and-retry
3853
3930
  // per RE doc u00a75.5 (Stainless SDK retries 500+ codes up to maxServiceRetries times)
3854
- if (
3855
- (response.status === 529 || response.status === 503) &&
3856
- serviceWideRetryCount < maxServiceRetries
3857
- ) {
3931
+ if (shouldServiceRetry(response.status, serviceWideRetryCount, maxServiceRetries)) {
3858
3932
  serviceWideRetryCount++;
3859
3933
 
3860
3934
  // Track consecutive 529s for model fallback
3861
3935
  if (response.status === 529) {
3862
3936
  consecutive529Count++;
3863
- if (consecutive529Count >= 3 && requestInit.body) {
3937
+ if (consecutive529Count >= CONSECUTIVE_529_FALLBACK_THRESHOLD && requestInit.body) {
3864
3938
  try {
3865
3939
  // QA fix: parse from requestInit.body (pre-transform) to avoid
3866
3940
  // double-transformation (mcp_ prefix, system blocks, metadata).
3867
3941
  const parsedForFallback = JSON.parse(requestInit.body);
3868
3942
  const currentModel = parsedForFallback.model || "";
3869
- let fallbackModel = null;
3870
- if (/opus-4-6|opus-4/i.test(currentModel))
3871
- fallbackModel = currentModel.replace(/opus/i, "sonnet");
3872
- else if (/sonnet-4-6|sonnet-4/i.test(currentModel))
3873
- fallbackModel = currentModel.replace(/sonnet/i, "haiku");
3943
+ const fallbackModel = selectFallbackModel(currentModel);
3874
3944
 
3875
3945
  if (fallbackModel) {
3876
3946
  parsedForFallback.model = fallbackModel;
@@ -3895,9 +3965,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3895
3965
  consecutive529Count = 0;
3896
3966
  }
3897
3967
 
3898
- const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 3);
3899
- const jitter = 1 - Math.random() * 0.25;
3900
- const sleepMs = Math.round(baseDelay * jitter * 1000);
3968
+ const sleepMs = computeServiceRetrySleepMs(serviceWideRetryCount);
3901
3969
  const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
3902
3970
  debugLog(`service-wide ${retryLabel} error, sleeping before retry`, {
3903
3971
  status: response.status,
@@ -3963,7 +4031,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3963
4031
  status: response.status,
3964
4032
  });
3965
4033
  }
3966
- return transformResponse(response);
4034
+ return transformResponse(response, undefined, undefined, correlationId);
3967
4035
  }
3968
4036
 
3969
4037
  // Success
@@ -4149,7 +4217,17 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4149
4217
  }
4150
4218
  : null;
4151
4219
 
4152
- return transformResponse(response, usageCallback, accountErrorCallback);
4220
+ return transformResponse(
4221
+ response,
4222
+ usageCallback
4223
+ ? (stats) => {
4224
+ usageCallback(stats);
4225
+ if (stats.lastStopReason) sessionMetrics.lastStopReason = stats.lastStopReason;
4226
+ }
4227
+ : null,
4228
+ accountErrorCallback,
4229
+ correlationId,
4230
+ );
4153
4231
  }
4154
4232
 
4155
4233
  // All accounts tried
@@ -4375,63 +4453,63 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4375
4453
  // Session-level cache & cost tracking (Phase 4)
4376
4454
  // ---------------------------------------------------------------------------
4377
4455
 
4378
- /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, totalWebSearchRequests: number, recentCacheRates: number[], sessionCostUsd: number, costBreakdown: {input: number, output: number, cacheRead: number, cacheWrite: number}, sessionStartTime: number, lastQuota: {tokens: number, requests: number, inputTokens: number, updatedAt: number, fiveHour: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, sevenDay: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, overallStatus: string|null, representativeClaim: string|null, fallback: string|null, fallbackPercentage: number|null, overageStatus: string|null, overageReason: string|null, lastPollAt: number}, lastStopReason: string | null, perModel: Record<string, {input: number, output: number, cacheRead: number, cacheWrite: number, costUsd: number, turns: number}>, lastModelId: string | null, lastRequestBody: string | null, tokenBudget: {limit: number, used: number, continuations: number, outputHistory: number[]}}} */
4379
4456
  /** Module-level config ref for functions outside AnthropicAuthPlugin closure. */
4380
4457
  let _pluginConfig = null;
4381
4458
 
4382
- /**
4383
- * Factory for the initial sessionMetrics shape. Returns a fresh object each
4384
- * call so the reset helper (and any future test hook) doesn't alias nested
4385
- * state (lastQuota, perModel, costBreakdown, tokenBudget, usedTools Set).
4386
- * Keep this in sync with the type annotation above.
4387
- */
4388
- function createInitialSessionMetrics() {
4459
+ const _debugSessionId = randomUUID().slice(0, 8);
4460
+ let _debugReqSeq = 0;
4461
+
4462
+ export function createDebugCorrelationId() {
4463
+ return `${_debugSessionId}-${(++_debugReqSeq).toString(36).padStart(4, "0")}`;
4464
+ }
4465
+
4466
+ export function isDebugSinkEnabled(config, sink) {
4467
+ return sink === "body" ? config.token_economy?.debug_dump_bodies === true : Boolean(config.debug);
4468
+ }
4469
+
4470
+ export function createDebugRequestDump(correlationId, timestamp, finalBody) {
4389
4471
  return {
4390
- turns: 0,
4391
- totalInput: 0,
4392
- totalOutput: 0,
4393
- totalCacheRead: 0,
4394
- totalCacheWrite: 0,
4395
- totalWebSearchRequests: 0,
4396
- recentCacheRates: [], // rolling window of last 5 turns
4397
- sessionCostUsd: 0,
4398
- costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4399
- sessionStartTime: Date.now(),
4400
- lastQuota: {
4401
- tokens: 0,
4402
- requests: 0,
4403
- inputTokens: 0,
4404
- updatedAt: 0,
4405
- // Window-based unified headers from response
4406
- fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4407
- sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4408
- // Overall/fallback/overage from response headers
4409
- overallStatus: null,
4410
- representativeClaim: null,
4411
- fallback: null,
4412
- fallbackPercentage: null,
4413
- overageStatus: null,
4414
- overageReason: null,
4415
- // Usage endpoint polling (A6)
4416
- lastPollAt: 0,
4417
- },
4418
- lastStopReason: null, // tracks most recent stop_reason for output cap escalation
4419
- perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
4420
- lastModelId: null,
4421
- lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
4422
- /** Token budget tracking (A9) */
4423
- tokenBudget: {
4424
- limit: 0, // 0 = unset
4425
- used: 0, // accumulated output tokens
4426
- continuations: 0,
4427
- outputHistory: [], // last 5 output token deltas
4428
- },
4429
- /** Tools used in this session (populated from assistant tool_use blocks in messages) */
4430
- usedTools: new Set(),
4472
+ filename: `req-${timestamp}-${correlationId}.json`,
4473
+ content: JSON.stringify({ correlationId, timestamp, bodyRedacted: redactString(finalBody) }),
4431
4474
  };
4432
4475
  }
4433
4476
 
4434
- const sessionMetrics = createInitialSessionMetrics();
4477
+ async function writeSseCapture(correlationId, buf, truncated) {
4478
+ const fs = await import("node:fs");
4479
+ const path = await import("node:path");
4480
+ const os = await import("node:os");
4481
+ const dir = path.join(os.homedir(), ".opencode", "opencode-anthropic-fix", "request-dumps");
4482
+ fs.mkdirSync(dir, { recursive: true });
4483
+ const existing = fs
4484
+ .readdirSync(dir)
4485
+ .filter((f) => f.startsWith("res-") && f.endsWith(".sse"))
4486
+ .sort();
4487
+ while (existing.length >= 10) {
4488
+ fs.unlinkSync(path.join(dir, existing.shift()));
4489
+ }
4490
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
4491
+ fs.writeFileSync(
4492
+ path.join(dir, `res-${ts}-${correlationId}.sse`),
4493
+ redactString(buf) + (truncated ? "\n[capture truncated at 256KB]" : ""),
4494
+ );
4495
+ }
4496
+
4497
+ export function createDebugOutgoingHeadersEntry(correlationId, timestamp, requestHeaders) {
4498
+ return [
4499
+ `\n=== ${timestamp} | corr=${correlationId} | OUTGOING request headers ===`,
4500
+ JSON.stringify(redactSecrets(requestHeaders), null, 2),
4501
+ "",
4502
+ ].join("\n");
4503
+ }
4504
+
4505
+ export function createDebugResponseHeadersEntry(correlationId, timestamp, response, debugHeaders) {
4506
+ return [
4507
+ `\n=== ${timestamp} | corr=${correlationId} | status=${response.status} ok=${response.ok} account=${debugHeaders.account} mgr=${debugHeaders.accountManager} ===`,
4508
+ `Rate-limit headers: ${JSON.stringify(debugHeaders.rateLimitHeaders, null, 2)}`,
4509
+ `All headers: ${JSON.stringify(debugHeaders.allHeaders, null, 2)}`,
4510
+ "",
4511
+ ].join("\n");
4512
+ }
4435
4513
 
4436
4514
  // ---------------------------------------------------------------------------
4437
4515
  // Adaptive 1M context state
@@ -4482,29 +4560,6 @@ const cacheBreakState = {
4482
4560
  boundaryStability: new Map(),
4483
4561
  };
4484
4562
 
4485
- /**
4486
- * Update per-boundary stability counters by diffing the current request's cache
4487
- * source hashes against the previous turn's. A boundary that is unchanged gets
4488
- * its counter incremented; a changed (or newly-appeared) boundary resets to 0.
4489
- *
4490
- * @param {Map<string,string>} current - hashes for this turn (source -> hash)
4491
- * @param {Map<string,string>} previous - hashes from the prior turn
4492
- * @param {Map<string,number>} stability - mutated in place
4493
- */
4494
- function updateBoundaryStability(current, previous, stability) {
4495
- for (const [source, hash] of current) {
4496
- if (previous.get(source) === hash) {
4497
- stability.set(source, (stability.get(source) || 0) + 1);
4498
- } else {
4499
- stability.set(source, 0);
4500
- }
4501
- }
4502
- // Drop boundaries that no longer appear so stale entries don't linger.
4503
- for (const source of [...stability.keys()]) {
4504
- if (!current.has(source)) stability.delete(source);
4505
- }
4506
- }
4507
-
4508
4563
  // ---------------------------------------------------------------------------
4509
4564
  // Microcompact state (Phase 3, Task 3.4)
4510
4565
  // ---------------------------------------------------------------------------
@@ -4518,31 +4573,6 @@ const microcompactState = {
4518
4573
  lastActivatedTurn: 0,
4519
4574
  };
4520
4575
 
4521
- /**
4522
- * Determine if microcompact betas should be injected based on estimated token usage.
4523
- * @param {number} estimatedTokens - Estimated prompt token count
4524
- * @param {object} config - Plugin config
4525
- * @returns {boolean}
4526
- */
4527
- function shouldMicrocompact(estimatedTokens, config) {
4528
- if (!config.microcompact?.enabled) return false;
4529
- const thresholdPct = config.microcompact.threshold_percent ?? 80;
4530
- // Use the model's context window. Default to 200K if unknown.
4531
- // Adaptive context may escalate to 1M, but we use the base 200K for threshold
4532
- // to be conservative (microcompact at 160K tokens is still valuable).
4533
- const contextWindow = 200_000;
4534
- const threshold = contextWindow * (thresholdPct / 100);
4535
- return estimatedTokens >= threshold;
4536
- }
4537
-
4538
- /**
4539
- * Build the list of microcompact betas to inject.
4540
- * @returns {string[]} Array of beta flag strings
4541
- */
4542
- function buildMicrocompactBetas() {
4543
- return ["clear_tool_uses_20250919", "clear_thinking_20251015"];
4544
- }
4545
-
4546
4576
  /**
4547
4577
  * Hash a string for cache source fingerprinting.
4548
4578
  * @param {string} content
@@ -4734,41 +4764,6 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
4734
4764
  * @param {string} bodyStr - JSON request body
4735
4765
  * @returns {Map<string, string>} source_id → hash
4736
4766
  */
4737
- /**
4738
- * Resolve the prompt-cache TTL for tool/message breakpoints, mirroring the
4739
- * decompiled Claude Code 2.1.154 `REH(querySource)` precedence.
4740
- *
4741
- * Precedence (highest first):
4742
- * 1. FORCE_PROMPT_CACHING_5M truthy -> "5m"
4743
- * 2. ENABLE_PROMPT_CACHING_1H truthy -> "1h"
4744
- * 3. role-scoping on AND not the main role -> "5m" (side-queries are cheap)
4745
- * 4. otherwise -> configuredTtl (main interactive thread, default "1h")
4746
- *
4747
- * @param {object} args
4748
- * @param {string} args.configuredTtl - Default TTL when 1h is eligible (usually "1h").
4749
- * @param {boolean} args.roleScopedTtl - Whether role-scoped 5m downgrade is enabled.
4750
- * @param {boolean} args.isMainForCache - True when the request is the main/interactive role.
4751
- * @param {boolean} [args.isSubagent] - True when the request is an opencode subagent
4752
- * (detected via the `x-parent-session-id` header). One-shot subagents prefer 5m.
4753
- * @param {Record<string, string | undefined>} [args.env] - Environment (defaults to process.env).
4754
- * @returns {string} The TTL string ("5m" or "1h"/configuredTtl).
4755
- */
4756
- function resolveCacheTtl({ configuredTtl, roleScopedTtl, isMainForCache, isSubagent = false, env = process.env }) {
4757
- // Env overrides (exact CC REH() behavior). isTruthyEnv mirrors CC's uH().
4758
- if (isTruthyEnv(env.FORCE_PROMPT_CACHING_5M)) return "5m";
4759
- if (isTruthyEnv(env.ENABLE_PROMPT_CACHING_1H)) return "1h";
4760
- // Subagent requests (opencode marks them with the x-parent-session-id header)
4761
- // are usually one-shot, and opencode runs them sequentially with a DIFFERENT
4762
- // system prompt than the main thread, so they rarely re-hit a 1h cache. Prefer
4763
- // the cheap 5m write tier (~1.25x base) over the 1h tier (~2x base) to avoid
4764
- // paying the long-cache write premium on a prefix that won't be reused. Gated
4765
- // on the same role-scoped master switch as the side-query downgrade below.
4766
- if (roleScopedTtl && isSubagent) return "5m";
4767
- // Role-scoped downgrade: non-main (side-query) requests use the cheap 5m tier.
4768
- if (roleScopedTtl && !isMainForCache) return "5m";
4769
- return configuredTtl;
4770
- }
4771
-
4772
4767
  /**
4773
4768
  * Read a single incoming request header by name (case-insensitive) from either a
4774
4769
  * `Request` input or a fetch `init.headers` (Headers | array | plain object).
@@ -4809,41 +4804,6 @@ function getIncomingHeader(input, requestInit, name) {
4809
4804
  return null;
4810
4805
  }
4811
4806
 
4812
- /**
4813
- * Decide whether to place the prompt-cache breakpoint on the last tool.
4814
- *
4815
- * Default (no stability data, or detector disabled): TRUE — exact Claude Code
4816
- * behavior (breakpoint on the last tool).
4817
- *
4818
- * Adaptive override: returns FALSE only when there is concrete evidence that the
4819
- * tool boundary is thrashing (a `tool:*` source changed within the last turn,
4820
- * i.e. stability 0) WHILE the system-prompt boundary is stable (unchanged for
4821
- * >= STABLE_TURNS turns). In that case the stable system-prompt breakpoint is a
4822
- * better cache anchor than a tool array that moves every turn.
4823
- *
4824
- * @param {Map<string, number> | null | undefined} stability - source -> consecutive-unchanged turns
4825
- * @returns {boolean} whether to stamp cache_control on the last tool
4826
- */
4827
- function shouldPlaceToolBreakpoint(stability) {
4828
- if (!stability || stability.size === 0) return true; // no data -> CC behavior
4829
- const STABLE_TURNS = 2;
4830
- const systemStability = stability.get("system_prompt");
4831
- const systemIsStable = typeof systemStability === "number" && systemStability >= STABLE_TURNS;
4832
- if (!systemIsStable) return true; // system not proven stable -> keep CC behavior
4833
-
4834
- // Is any tool boundary thrashing right now (changed within the last turn)?
4835
- let toolThrashing = false;
4836
- for (const [source, turns] of stability) {
4837
- if (source.startsWith("tool:") && turns === 0) {
4838
- toolThrashing = true;
4839
- break;
4840
- }
4841
- }
4842
- // Skip the last-tool breakpoint only when tools thrash AND system is the
4843
- // stable anchor. Otherwise fall back to CC behavior.
4844
- return !toolThrashing;
4845
- }
4846
-
4847
4807
  function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
4848
4808
  const hashes = new Map();
4849
4809
  try {
@@ -5020,88 +4980,6 @@ function classifyApiRequest(body) {
5020
4980
  }
5021
4981
  }
5022
4982
 
5023
- // ---------------------------------------------------------------------------
5024
- // Token Budget Parsing & Enforcement (A9)
5025
- // ---------------------------------------------------------------------------
5026
-
5027
- /**
5028
- * Parse natural-language budget expressions from user messages.
5029
- * Supports: +500k, 500,000, 2M, 2 million, "spend 500k", "use 2M tokens", "budget: 1M".
5030
- * Only scans the last user message to avoid re-triggering from history.
5031
- *
5032
- * @param {Array<{role: string, content: string | Array<{type: string, text?: string}>}>} messages
5033
- * @returns {number} Parsed token count, or 0 if no budget expression found
5034
- */
5035
- function parseNaturalLanguageBudget(messages) {
5036
- if (!Array.isArray(messages) || messages.length === 0) return 0;
5037
-
5038
- // Find the last user message
5039
- let lastUserText = "";
5040
- for (let i = messages.length - 1; i >= 0; i--) {
5041
- if (messages[i].role === "user") {
5042
- const content = messages[i].content;
5043
- if (typeof content === "string") {
5044
- lastUserText = content;
5045
- } else if (Array.isArray(content)) {
5046
- lastUserText = content
5047
- .filter((b) => b.type === "text" && typeof b.text === "string")
5048
- .map((b) => b.text)
5049
- .join(" ");
5050
- }
5051
- break;
5052
- }
5053
- }
5054
- if (!lastUserText) return 0;
5055
-
5056
- // Patterns ordered from most specific to least
5057
- const patterns = [
5058
- /\buse\s+(\d[\d,]*)\s*([mk])\s*tokens?\b/i,
5059
- /\bspend\s+(\d[\d,]*)\s*([mk])?\b/i,
5060
- /\bbudget[:\s]+(\d[\d,]*)\s*([mk])?\b/i,
5061
- /\+(\d[\d,]*)\s*([mk])\b/i,
5062
- /\b(\d[\d,]*)\s*million\s*tokens?\b/i,
5063
- ];
5064
-
5065
- for (const re of patterns) {
5066
- const m = lastUserText.match(re);
5067
- if (m) {
5068
- const num = parseFloat(m[1].replace(/,/g, ""));
5069
- if (isNaN(num) || num <= 0) continue;
5070
- const suffix = (m[2] || "").toLowerCase();
5071
- if (re === patterns[4]) {
5072
- // "N million tokens" — the regex has no suffix group
5073
- return num * 1_000_000;
5074
- }
5075
- if (suffix === "m") return num * 1_000_000;
5076
- if (suffix === "k") return num * 1_000;
5077
- // No suffix — treat as absolute count
5078
- return num;
5079
- }
5080
- }
5081
- return 0;
5082
- }
5083
-
5084
- /**
5085
- * Inject a token budget status block into the system prompt.
5086
- * Prepends a text block with budget progress and threshold info.
5087
- *
5088
- * @param {Array<{type: string, text?: string, [k: string]: any}>} systemBlocks
5089
- * @param {{limit: number, used: number, continuations: number}} budget
5090
- * @param {number} threshold - Completion threshold (0-1, e.g. 0.9)
5091
- * @returns {Array<{type: string, text?: string, [k: string]: any}>}
5092
- */
5093
- function injectTokenBudgetBlock(systemBlocks, budget, threshold) {
5094
- if (!budget || budget.limit <= 0) return systemBlocks;
5095
- const pct = ((budget.used / budget.limit) * 100).toFixed(0);
5096
- const thresholdTokens = Math.round(budget.limit * threshold);
5097
- const remaining = Math.max(0, budget.limit - budget.used);
5098
- const block = {
5099
- type: "text",
5100
- text: `Token budget: ${budget.used.toLocaleString()}/${budget.limit.toLocaleString()} tokens used (${pct}%). Stop generating at ${thresholdTokens.toLocaleString()} tokens. Remaining: ${remaining.toLocaleString()} tokens.`,
5101
- };
5102
- return [block, ...(systemBlocks || [])];
5103
- }
5104
-
5105
4983
  /**
5106
4984
  * Detect diminishing returns: ≥3 continuations AND last 3 output deltas all < 500 tokens.
5107
4985
  *
@@ -5131,54 +5009,6 @@ function estimatePromptTokens(bodyString) {
5131
5009
  }
5132
5010
  }
5133
5011
 
5134
- /**
5135
- * Estimate prompt tokens from an already-parsed request body object.
5136
- * Avoids redundant JSON.parse when the caller already has the parsed object.
5137
- * @param {object} parsed - The parsed request body
5138
- * @returns {number} Estimated token count
5139
- */
5140
- function estimatePromptTokensFromParsed(parsed) {
5141
- if (!parsed || typeof parsed !== "object") return 0;
5142
- let charCount = 0;
5143
-
5144
- // Count system prompt text
5145
- if (Array.isArray(parsed.system)) {
5146
- for (const block of parsed.system) {
5147
- if (block.type === "text" && typeof block.text === "string") {
5148
- charCount += block.text.length;
5149
- }
5150
- }
5151
- } else if (typeof parsed.system === "string") {
5152
- charCount += parsed.system.length;
5153
- }
5154
-
5155
- // Count messages content (text blocks, tool results) — skip tool definitions
5156
- if (Array.isArray(parsed.messages)) {
5157
- for (const msg of parsed.messages) {
5158
- if (typeof msg.content === "string") {
5159
- charCount += msg.content.length;
5160
- } else if (Array.isArray(msg.content)) {
5161
- for (const block of msg.content) {
5162
- if (block.type === "text" && typeof block.text === "string") {
5163
- charCount += block.text.length;
5164
- } else if (block.type === "tool_result" && typeof block.content === "string") {
5165
- charCount += block.content.length;
5166
- } else if (block.type === "tool_use") {
5167
- // Count serialized input as tokens
5168
- charCount += JSON.stringify(block.input || {}).length;
5169
- } else if (block.type === "image" || block.type === "image_url") {
5170
- // Images: ~2000 tokens per image (Anthropic tile-based counting)
5171
- charCount += 8000; // 2000 tokens * 4 chars/token
5172
- }
5173
- }
5174
- }
5175
- }
5176
- }
5177
-
5178
- // 4 chars/token heuristic for text content (reasonable for English + code + JSON)
5179
- return Math.ceil(charCount / 4);
5180
- }
5181
-
5182
5012
  /**
5183
5013
  * Analyze a request body to produce a token breakdown by role and tool.
5184
5014
  * Used by `/anthropic context` command.
@@ -5499,16 +5329,6 @@ function updateSessionMetrics(usage, model) {
5499
5329
  }
5500
5330
  }
5501
5331
 
5502
- /**
5503
- * Get rolling average cache hit rate over last 5 turns.
5504
- * @returns {number} 0-1
5505
- */
5506
- function getAverageCacheHitRate() {
5507
- const rates = sessionMetrics.recentCacheRates;
5508
- if (rates.length === 0) return 0;
5509
- return rates.reduce((a, b) => a + b, 0) / rates.length;
5510
- }
5511
-
5512
5332
  /**
5513
5333
  * Write cache transparency stats to a well-known JSON file for TUI consumption.
5514
5334
  * The OpenCode TUI watches this file to display cache metrics in the status bar.
@@ -5752,9 +5572,6 @@ process.once("beforeExit", _beforeExitHandler);
5752
5572
  // Request building helpers (extracted from original fetch interceptor)
5753
5573
  // ---------------------------------------------------------------------------
5754
5574
 
5755
- const BILLING_HASH_SALT = "59cf53e54c78";
5756
- const BILLING_HASH_INDICES = [4, 7, 20];
5757
-
5758
5575
  // cch attestation: RE-ENABLED with xxHash64 (matching Bun binary's Attestation.zig).
5759
5576
  // The compiled Bun binary computes cch dynamically: xxHash64(body, seed) & 0xFFFFF.
5760
5577
  // Captured real CC v2.1.107 request shows cch=6d00f (5-hex-char, 20-bit masked hash).
@@ -5783,19 +5600,6 @@ async function computeAndReplaceCCH(body) {
5783
5600
  return body.replace("cch=00000", `cch=${cch}`);
5784
5601
  }
5785
5602
 
5786
- /**
5787
- * Compute the billing cache hash (cch) matching Claude Code's NP1() function.
5788
- * SHA256(salt + chars_at_indices[4,7,20]_from_first_user_msg + version).slice(0,3)
5789
- * @param {string} firstUserMessage
5790
- * @param {string} version
5791
- * @returns {string}
5792
- */
5793
- function computeBillingCacheHash(firstUserMessage, version) {
5794
- const chars = BILLING_HASH_INDICES.map((i) => firstUserMessage[i] || "0").join("");
5795
- const input = `${BILLING_HASH_SALT}${chars}${version}`;
5796
- return createHashCrypto("sha256").update(input).digest("hex").slice(0, 3);
5797
- }
5798
-
5799
5603
  /**
5800
5604
  * Extract the text content of the first user message for billing hash computation.
5801
5605
  * @param {any[] | undefined} messages
@@ -5839,2921 +5643,316 @@ function computeBillingCacheHash(firstUserMessage, version) {
5839
5643
  * @param {Array} messages — The messages array from the parsed request body
5840
5644
  * @returns {Array} — Repaired messages array
5841
5645
  */
5646
+
5647
+ const _ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
5648
+ // OpenCode SDK betas that leak through the host's Anthropic SDK but are NOT
5649
+ // part of CC's beta vocabulary. Filtered out when signature emulation is on.
5650
+ const USER_ID_STORAGE_FILE = "anthropic-signature-user-id";
5651
+ const DEBUG_SYSTEM_PROMPT_ENV = "OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT";
5842
5652
  /**
5843
- * Apply context-hint compaction to a message array. Mirrors real CC's
5844
- * `applyHintEdits` (d85) + `qD4` microcompact: clears thinking/redacted_thinking
5845
- * blocks from assistant messages and replaces old tool_result content with a
5846
- * placeholder, keeping the last few tool results intact. Used on 422/424
5847
- * responses before retrying.
5848
- *
5849
- * @param {Array} messages — Parsed messages array
5850
- * @param {object} [opts]
5851
- * @param {number} [opts.keepRecentToolResults=8] — How many most-recent tool_result blocks to preserve verbatim
5852
- * @param {string} [opts.clearedPlaceholder] — Replacement content for older tool_result blocks
5853
- * @returns {{ messages: Array, changed: boolean, stats: { thinkingCleared: number, toolResultsCleared: number } }}
5653
+ * Returns the persistent device ID (64-char hex string).
5654
+ * Migrates legacy UUID-format values to the new 64-hex format automatically.
5655
+ * @returns {string}
5854
5656
  */
5855
- function applyContextHintCompaction(messages, opts = {}) {
5856
- if (!Array.isArray(messages) || messages.length === 0) {
5857
- return { messages, changed: false, stats: { thinkingCleared: 0, toolResultsCleared: 0 } };
5858
- }
5859
- const keepRecent = opts.keepRecentToolResults ?? 8;
5860
- const placeholder = opts.clearedPlaceholder ?? "[Old tool result content cleared]";
5861
-
5862
- // First pass: count tool_result blocks so we know which are "old" vs "recent".
5863
- const toolResultRefs = [];
5864
- for (let i = 0; i < messages.length; i++) {
5865
- const msg = messages[i];
5866
- if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
5867
- for (let j = 0; j < msg.content.length; j++) {
5868
- if (msg.content[j]?.type === "tool_result") {
5869
- toolResultRefs.push({ msgIdx: i, blockIdx: j });
5870
- }
5657
+ function getOrCreateDeviceId() {
5658
+ const configDir = getConfigDir();
5659
+ const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
5660
+
5661
+ try {
5662
+ if (existsSync(userIdPath)) {
5663
+ const existing = readFileSync(userIdPath, "utf-8").trim();
5664
+ if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
5871
5665
  }
5666
+ } catch {
5667
+ // fall through and generate a new id
5872
5668
  }
5873
- const oldCutoff = Math.max(0, toolResultRefs.length - keepRecent);
5874
- const oldSet = new Set(toolResultRefs.slice(0, oldCutoff).map((r) => `${r.msgIdx}:${r.blockIdx}`));
5875
-
5876
- let thinkingCleared = 0;
5877
- let toolResultsCleared = 0;
5878
- const out = messages.map((msg, i) => {
5879
- if (!Array.isArray(msg.content)) return msg;
5880
- if (msg.role === "assistant") {
5881
- const newContent = msg.content.filter((block) => {
5882
- if (block?.type === "thinking" || block?.type === "redacted_thinking") {
5883
- thinkingCleared += 1;
5884
- return false;
5885
- }
5886
- return true;
5887
- });
5888
- if (newContent.length !== msg.content.length) {
5889
- return { ...msg, content: newContent };
5890
- }
5891
- return msg;
5892
- }
5893
- if (msg.role === "user") {
5894
- let mutated = false;
5895
- const newContent = msg.content.map((block, j) => {
5896
- if (block?.type !== "tool_result") return block;
5897
- const key = `${i}:${j}`;
5898
- if (!oldSet.has(key)) return block;
5899
- toolResultsCleared += 1;
5900
- mutated = true;
5901
- // Replace content with placeholder, preserve tool_use_id
5902
- return {
5903
- ...block,
5904
- content: placeholder,
5905
- };
5906
- });
5907
- return mutated ? { ...msg, content: newContent } : msg;
5908
- }
5909
- return msg;
5910
- });
5911
5669
 
5912
- return {
5913
- messages: out,
5914
- changed: thinkingCleared > 0 || toolResultsCleared > 0,
5915
- stats: { thinkingCleared, toolResultsCleared },
5916
- };
5670
+ const generated = randomBytes(32).toString("hex");
5671
+ try {
5672
+ mkdirSync(configDir, { recursive: true });
5673
+ writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
5674
+ } catch {
5675
+ // Ignore filesystem errors; caller still gets generated ID for this runtime.
5676
+ }
5677
+ return generated;
5917
5678
  }
5918
5679
 
5919
5680
  /**
5920
- * Tools whose output is trivially reproducible by re-running with the same
5921
- * arguments. Stateful tools (bash, edit, write, etc.) never dedupe — their
5922
- * outputs may reflect non-idempotent side effects that the transcript needs
5923
- * to preserve.
5681
+ * @returns {boolean}
5924
5682
  */
5925
- const REPRODUCIBLE_TOOL_NAMES = new Set(["read", "grep", "glob", "ls", "list", "find"]);
5683
+ function shouldDebugSystemPrompt() {
5684
+ return isTruthyEnv(process.env[DEBUG_SYSTEM_PROMPT_ENV]);
5685
+ }
5926
5686
 
5927
5687
  /**
5928
- * Title-case a tool name for the stub string ("read" → "Read", "grep" → "Grep").
5929
- * Pure; no locale.
5688
+ * @param {string | undefined} value
5689
+ * @returns {boolean}
5930
5690
  */
5931
- function titleCaseToolName(name) {
5932
- if (typeof name !== "string" || name.length === 0) return "";
5933
- return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
5934
- }
5935
-
5936
5691
  /**
5937
- * Minimal deterministic stable-stringify for args canonicalization.
5938
- * Sorts object keys at every depth before serialization so that
5939
- * `{a:1,b:2}` and `{b:2,a:1}` produce the same string.
5940
- *
5941
- * Arrays are traversed in order. Non-plain objects (Date, Map, etc.) fall
5942
- * through to `JSON.stringify` — we don't expect them in tool args, but the
5943
- * fallback keeps the function total.
5692
+ * @param {string | undefined} value
5693
+ * @returns {boolean}
5944
5694
  */
5945
- function stableStringifyForDedupe(value) {
5946
- if (value === null || typeof value !== "object") return JSON.stringify(value);
5947
- if (Array.isArray(value)) {
5948
- return "[" + value.map((v) => stableStringifyForDedupe(v)).join(",") + "]";
5949
- }
5950
- const keys = Object.keys(value).sort();
5951
- const parts = [];
5952
- for (const k of keys) {
5953
- parts.push(JSON.stringify(k) + ":" + stableStringifyForDedupe(value[k]));
5954
- }
5955
- return "{" + parts.join(",") + "}";
5956
- }
5957
-
5958
5695
  /**
5959
- * Session-wide tool-result dedupe. Pure over `messages`.
5960
- *
5961
- * Walks the conversation once to collect every `tool_use` + `tool_result`
5962
- * pair where the tool is in `REPRODUCIBLE_TOOL_NAMES` (case-insensitive).
5963
- * Groups them by `(toolName, stableStringify(args))`. For every group
5964
- * containing more than one call, keeps the LATEST result verbatim and
5965
- * replaces each earlier result's `content` with a stub:
5966
- *
5967
- * `[<ToolTitleCase> of <argsKey> superseded by later read at msg #<N>]`
5968
- *
5969
- * where `N` is the message index of the latest call's user-message.
5970
- *
5971
- * `tool_use_id` and any other `tool_result` fields are preserved. Assistant
5972
- * `tool_use` blocks are NEVER modified — only the paired user `tool_result`.
5973
- *
5974
- * Cache-stable: decision is a pure function of message history, so rerunning
5975
- * over an unchanged prefix yields byte-identical output.
5976
- *
5977
- * @param {Array} messages — Parsed messages array
5978
- * @returns {{ messages: Array, changed: boolean, stats: { deduped: number } }}
5696
+ * @returns {boolean}
5979
5697
  */
5980
- function applySessionToolResultDedupe(messages) {
5981
- if (!Array.isArray(messages) || messages.length === 0) {
5982
- return { messages, changed: false, stats: { deduped: 0 } };
5983
- }
5984
-
5985
- // Pass 1: build tool_use_id → { name, argsKey } for reproducible tools.
5986
- const idToMeta = new Map();
5987
- for (let i = 0; i < messages.length; i++) {
5988
- const msg = messages[i];
5989
- if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
5990
- for (const block of msg.content) {
5991
- if (block?.type !== "tool_use") continue;
5992
- const name = typeof block.name === "string" ? block.name : "";
5993
- if (!REPRODUCIBLE_TOOL_NAMES.has(name.toLowerCase())) continue;
5994
- const argsKey = stableStringifyForDedupe(block.input ?? {});
5995
- idToMeta.set(block.id, { name, argsKey });
5996
- }
5997
- }
5998
-
5999
- // Pass 2: collect tool_result locations by group key, preserving order.
6000
- /** @type {Map<string, Array<{msgIdx: number, blockIdx: number, toolUseId: string, name: string, argsKey: string}>>} */
6001
- const groups = new Map();
6002
- for (let i = 0; i < messages.length; i++) {
6003
- const msg = messages[i];
6004
- if (msg?.role !== "user" || !Array.isArray(msg.content)) continue;
6005
- for (let j = 0; j < msg.content.length; j++) {
6006
- const block = msg.content[j];
6007
- if (block?.type !== "tool_result") continue;
6008
- const meta = idToMeta.get(block.tool_use_id);
6009
- if (!meta) continue;
6010
- const groupKey = meta.name.toLowerCase() + "\u0000" + meta.argsKey;
6011
- let arr = groups.get(groupKey);
6012
- if (!arr) {
6013
- arr = [];
6014
- groups.set(groupKey, arr);
6015
- }
6016
- arr.push({
6017
- msgIdx: i,
6018
- blockIdx: j,
6019
- toolUseId: block.tool_use_id,
6020
- name: meta.name,
6021
- argsKey: meta.argsKey,
6022
- });
6023
- }
6024
- }
6025
-
6026
- // Build supersede map: "msgIdx:blockIdx" → stub string.
6027
- const supersedeStubs = new Map();
6028
- let deduped = 0;
6029
- // Deterministic iteration: sort group entries by group key before processing.
6030
- const sortedEntries = Array.from(groups.entries()).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
6031
- for (const [, occurrences] of sortedEntries) {
6032
- if (occurrences.length < 2) continue;
6033
- const latest = occurrences[occurrences.length - 1];
6034
- const stub =
6035
- "[" +
6036
- titleCaseToolName(latest.name) +
6037
- " of " +
6038
- latest.argsKey +
6039
- " superseded by later read at msg #" +
6040
- latest.msgIdx +
6041
- "]";
6042
- for (let k = 0; k < occurrences.length - 1; k++) {
6043
- const occ = occurrences[k];
6044
- supersedeStubs.set(occ.msgIdx + ":" + occ.blockIdx, stub);
6045
- deduped += 1;
6046
- }
6047
- }
6048
-
6049
- if (deduped === 0) {
6050
- return { messages, changed: false, stats: { deduped: 0 } };
6051
- }
6052
-
6053
- // Pass 3: rewrite. Preserve tool_use_id and other fields.
6054
- const out = messages.map((msg, i) => {
6055
- if (msg?.role !== "user" || !Array.isArray(msg.content)) return msg;
6056
- let mutated = false;
6057
- const newContent = msg.content.map((block, j) => {
6058
- if (block?.type !== "tool_result") return block;
6059
- const stub = supersedeStubs.get(i + ":" + j);
6060
- if (!stub) return block;
6061
- mutated = true;
6062
- return { ...block, content: stub };
6063
- });
6064
- return mutated ? { ...msg, content: newContent } : msg;
6065
- });
6066
5698
 
6067
- return { messages: out, changed: true, stats: { deduped } };
6068
- }
5699
+ /**
5700
+ * @returns {Record<string, string>}
5701
+ */
6069
5702
 
6070
5703
  /**
6071
- * Dispatch wrapper: only apply `applySessionToolResultDedupe` when the
6072
- * `token_economy_strategies.tool_result_dedupe_session_wide` flag is true.
6073
- * When disabled, returns the input `messages` by identity (not a copy) so
6074
- * callers can cheaply detect "no-op" with reference equality.
6075
- *
6076
- * @param {Array} messages
6077
- * @param {object} [config]
6078
- * @returns {Array}
5704
+ * @returns {string}
6079
5705
  */
6080
- function maybeApplySessionToolResultDedupe(messages, config) {
6081
- const flag = config?.token_economy_strategies?.tool_result_dedupe_session_wide;
6082
- if (flag !== true) return messages;
6083
- const result = applySessionToolResultDedupe(messages);
6084
- return result.messages;
5706
+ function getClaudeEntrypoint() {
5707
+ return process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
6085
5708
  }
6086
5709
 
6087
5710
  /**
6088
- * TTL-based thinking strip. When the time since the last strip exceeds the
6089
- * cache TTL (roughly the point at which the prompt prefix cache would expire),
6090
- * remove all `thinking` / `redacted_thinking` blocks from prior assistant
6091
- * messages. Mirrors CC's `logThinkingClearLatched("ttl", ...)`.
6092
- *
6093
- * We keep the MOST RECENT assistant's thinking intact — chain-of-thought
6094
- * continuity for the current turn matters; older ones don't.
6095
- *
6096
- * @param {Array} messages
6097
- * @param {{ lastClearMs: number, ttlMs: number, now?: number }} ctx
6098
- * @returns {{ messages: Array, changed: boolean, cleared: number, ranStripAt: number }}
5711
+ * @param {string | undefined} model
5712
+ * @returns {string | undefined}
6099
5713
  */
6100
- function applyTtlThinkingStrip(messages, ctx) {
6101
- const now = ctx.now ?? Date.now();
6102
- if (!Array.isArray(messages) || messages.length === 0) {
6103
- return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
6104
- }
6105
- if (ctx.lastClearMs > 0 && now - ctx.lastClearMs < ctx.ttlMs) {
6106
- return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
6107
- }
5714
+ function logTransformedSystemPrompt(body) {
5715
+ if (!shouldDebugSystemPrompt()) return;
5716
+ if (!body || typeof body !== "string") return;
6108
5717
 
6109
- // Find the last assistant message index — preserve its thinking.
6110
- let lastAsstIdx = -1;
6111
- for (let i = messages.length - 1; i >= 0; i--) {
6112
- if (messages[i]?.role === "assistant") {
6113
- lastAsstIdx = i;
6114
- break;
6115
- }
5718
+ try {
5719
+ const parsed = JSON.parse(body);
5720
+ if (!Object.prototype.hasOwnProperty.call(parsed, "system")) return;
5721
+ if (isTitleGeneratorSystemBlocks(normalizeSystemTextBlocks(parsed.system))) return;
5722
+ console.error(
5723
+ "[opencode-anthropic-auth][system-debug] transformed system:",
5724
+ JSON.stringify(parsed.system, null, 2),
5725
+ );
5726
+ } catch {
5727
+ // Ignore parse errors in debug logging path.
6116
5728
  }
6117
-
6118
- let cleared = 0;
6119
- const out = messages.map((msg, i) => {
6120
- if (msg.role !== "assistant" || i === lastAsstIdx || !Array.isArray(msg.content)) {
6121
- return msg;
6122
- }
6123
- const newContent = msg.content.filter((b) => {
6124
- if (b?.type === "thinking" || b?.type === "redacted_thinking") {
6125
- cleared += 1;
6126
- return false;
6127
- }
6128
- return true;
6129
- });
6130
- return newContent.length !== msg.content.length ? { ...msg, content: newContent } : msg;
6131
- });
6132
-
6133
- return { messages: out, changed: cleared > 0, cleared, ranStripAt: cleared > 0 ? now : ctx.lastClearMs };
6134
5729
  }
6135
5730
 
6136
5731
  /**
6137
- * Proactive microcompact — client-side, runs BEFORE the request goes out.
6138
- * At or above `percent` of the model's context window, replace old
6139
- * tool_result.content with a placeholder (keeping last `keepRecent` verbatim).
6140
- *
6141
- * Returns the new messages array + change stats.
6142
- *
6143
- * @param {Array} messages
6144
- * @param {{ estimatedTokens: number, contextWindow: number, percent: number, keepRecent: number }} ctx
6145
- * @returns {{ messages: Array, changed: boolean, cleared: number, triggered: boolean }}
5732
+ * @param {string | undefined} body
5733
+ * @returns {boolean}
6146
5734
  */
6147
- function applyProactiveMicrocompact(messages, ctx) {
6148
- const threshold = ctx.contextWindow * (ctx.percent / 100);
6149
- if (ctx.estimatedTokens < threshold) {
6150
- return { messages, changed: false, cleared: 0, triggered: false };
6151
- }
6152
- const result = applyContextHintCompaction(messages, { keepRecentToolResults: ctx.keepRecent });
6153
- return {
6154
- messages: result.messages,
6155
- changed: result.changed,
6156
- cleared: result.stats.toolResultsCleared,
6157
- triggered: true,
6158
- };
6159
- }
6160
5735
 
6161
5736
  /**
6162
- * Stable tool ordering — sort tools by name so the system-prompt prefix stays
6163
- * cache-stable across turns. Safe: tool semantics are name-based, not index-based.
6164
- *
6165
- * @param {any[]} tools
6166
- * @returns {any[]}
5737
+ * @param {string | undefined} body
5738
+ * @returns {boolean}
6167
5739
  */
6168
- function applyStableToolOrdering(tools) {
6169
- if (!Array.isArray(tools) || tools.length < 2) return tools;
6170
- // Preserve a pinned "first" for tools whose position is load-bearing (none today).
6171
- return [...tools].sort((a, b) => {
6172
- const an = typeof a?.name === "string" ? a.name : "";
6173
- const bn = typeof b?.name === "string" ? b.name : "";
6174
- return an < bn ? -1 : an > bn ? 1 : 0;
6175
- });
5740
+ function supportsThinking(model) {
5741
+ if (!model) return true;
5742
+ return /claude|sonnet|opus|haiku/i.test(model);
6176
5743
  }
6177
5744
 
6178
5745
  /**
6179
- * Tool schema deferral — replace the `input_schema` of deferred tools with a
6180
- * minimal placeholder until the tool has been invoked in this session.
6181
- *
6182
- * @param {any[]} tools
6183
- * @param {{ deferred: Set<string>, invoked: Set<string> }} ctx
6184
- * @returns {{ tools: any[], deferredCount: number }}
5746
+ * @param {string | undefined} body
5747
+ * @returns {boolean}
6185
5748
  */
6186
- function applyToolSchemaDeferral(tools, ctx) {
6187
- if (!Array.isArray(tools) || ctx.deferred.size === 0) {
6188
- return { tools, deferredCount: 0 };
6189
- }
6190
- let deferredCount = 0;
6191
- const out = tools.map((t) => {
6192
- const name = typeof t?.name === "string" ? t.name : "";
6193
- if (!ctx.deferred.has(name) || ctx.invoked.has(name)) return t;
6194
- deferredCount += 1;
6195
- // Minimal schema — `type:object` with no properties is accepted by the API.
6196
- return {
6197
- ...t,
6198
- input_schema: { type: "object", properties: {}, additionalProperties: true },
6199
- };
6200
- });
6201
- return { tools: out, deferredCount };
6202
- }
6203
5749
 
6204
5750
  /**
6205
- * Adaptive thinking — zero the thinking budget for trivially simple follow-ups.
6206
- * "Simple" heuristic: most recent user message is short (<200 chars), no file
6207
- * references, and the conversation is past turn 1 (so we have context).
6208
- *
6209
- * @param {any} parsed Parsed request body (mutated in place if simple)
6210
- * @returns {{ applied: boolean, previousBudget: number | null }}
5751
+ * @param {string | undefined} body
5752
+ * @returns {boolean}
6211
5753
  */
6212
- function applyAdaptiveThinkingZero(parsed) {
6213
- if (!parsed || !parsed.thinking || parsed.thinking.type !== "enabled") {
6214
- return { applied: false, previousBudget: null };
6215
- }
6216
- const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
6217
- if (messages.length < 2) return { applied: false, previousBudget: null };
6218
- const last = messages[messages.length - 1];
6219
- if (!last || last.role !== "user") return { applied: false, previousBudget: null };
6220
-
6221
- let userText = "";
6222
- if (typeof last.content === "string") userText = last.content;
6223
- else if (Array.isArray(last.content)) {
6224
- for (const b of last.content) {
6225
- if (b?.type === "text" && typeof b.text === "string") userText += b.text;
6226
- if (b?.type === "tool_result") return { applied: false, previousBudget: null };
6227
- }
6228
- }
6229
- if (userText.length > 200) return { applied: false, previousBudget: null };
6230
- if (/\b(analyze|refactor|design|review|audit|plan)\b/i.test(userText)) {
6231
- return { applied: false, previousBudget: null };
6232
- }
6233
-
6234
- const previousBudget = typeof parsed.thinking.budget_tokens === "number" ? parsed.thinking.budget_tokens : null;
6235
- // "Zero" means remove thinking entirely — API disallows budget_tokens:0.
6236
- delete parsed.thinking;
6237
- if (typeof parsed.temperature !== "number") parsed.temperature = 1;
6238
- return { applied: true, previousBudget };
6239
- }
6240
5754
 
6241
5755
  /**
6242
- * Cross-turn tool_result dedupe — when the same (tool name, input) pair
6243
- * appeared earlier in the conversation, replace the later result content
6244
- * with a pointer string. Safe-set only: Read, Grep, Glob, LS.
6245
- *
6246
- * @param {Array} messages
6247
- * @param {{ seen: Map<string, string>, safeTools: Set<string> }} ctx
6248
- * @returns {{ messages: Array, changed: boolean, deduped: number }}
5756
+ * @param {URL | null} requestUrl
5757
+ * @returns {"anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle"}
6249
5758
  */
6250
- function applyToolResultDedupe(messages, ctx) {
6251
- if (!Array.isArray(messages) || messages.length === 0) {
6252
- return { messages, changed: false, deduped: 0 };
6253
- }
6254
- // First pass: build map of tool_use_id → { name, inputHash } from assistant messages.
6255
- const idToKey = new Map();
6256
- for (const msg of messages) {
6257
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
6258
- for (const b of msg.content) {
6259
- if (b?.type !== "tool_use") continue;
6260
- if (!ctx.safeTools.has(b.name)) continue;
6261
- const inputStr = JSON.stringify(b.input ?? {});
6262
- idToKey.set(b.id, `${b.name}::${inputStr}`);
6263
- }
6264
- }
6265
-
6266
- let deduped = 0;
6267
- const out = messages.map((msg) => {
6268
- if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
6269
- let mutated = false;
6270
- const newContent = msg.content.map((b) => {
6271
- if (b?.type !== "tool_result") return b;
6272
- const key = idToKey.get(b.tool_use_id);
6273
- if (!key) return b;
6274
- const firstSeen = ctx.seen.get(key);
6275
- if (firstSeen && firstSeen !== b.tool_use_id) {
6276
- deduped += 1;
6277
- mutated = true;
6278
- return { ...b, content: `[Identical to tool_use_id=${firstSeen}]` };
6279
- }
6280
- if (!firstSeen) ctx.seen.set(key, b.tool_use_id);
6281
- return b;
6282
- });
6283
- return mutated ? { ...msg, content: newContent } : msg;
6284
- });
6285
-
6286
- return { messages: out, changed: deduped > 0, deduped };
6287
- }
6288
5759
 
6289
5760
  /**
6290
- * Trailing-summary trimmer — strip the final text block of past assistant
6291
- * messages if it looks like a summary (ends with "...done" / "summary" /
6292
- * numbered list / "I've X'd Y"). Only applies to messages past the last one.
5761
+ * Classify a request by inferred role, mirroring CC's `querySource` gate.
5762
+ * CC gates features like context-hint on `querySource.startsWith("repl_main_thread")`.
5763
+ * We don't have that string on the wire, so we infer from body shape.
5764
+ *
5765
+ * Returns one of:
5766
+ * - "main" → interactive main thread (long system, normal max_tokens, messages present)
5767
+ * - "title" → title / name generation (tiny max_tokens, 1 message)
5768
+ * - "small" → short background query (small max_tokens but not title)
5769
+ * - "empty" → pre-warm / no messages
5770
+ * - "unknown" → treat as main for safety
6293
5771
  *
6294
- * @param {Array} messages
6295
- * @returns {{ messages: Array, changed: boolean, trimmed: number }}
5772
+ * @param {any} parsed Parsed request body
5773
+ * @returns {"main"|"title"|"small"|"empty"|"unknown"}
6296
5774
  */
6297
- function applyTrailingSummaryTrim(messages) {
6298
- if (!Array.isArray(messages) || messages.length < 2) {
6299
- return { messages, changed: false, trimmed: 0 };
6300
- }
6301
- const SUMMARY_PATTERNS = [
6302
- /\b(summary|summar(y|ised|ized)):/i,
6303
- /\bto summari[sz]e\b/i,
6304
- /^\s*in (summary|short|brief)/im,
6305
- /\bi['']ve (done|completed|implemented|added|updated|fixed) /i,
6306
- /\bthat's it\b/i,
6307
- ];
6308
-
6309
- let lastAsstIdx = -1;
6310
- for (let i = messages.length - 1; i >= 0; i--) {
6311
- if (messages[i]?.role === "assistant") {
6312
- lastAsstIdx = i;
6313
- break;
6314
- }
6315
- }
6316
-
6317
- let trimmed = 0;
6318
- const out = messages.map((msg, i) => {
6319
- if (msg.role !== "assistant" || i === lastAsstIdx) return msg;
6320
- if (!Array.isArray(msg.content)) return msg;
6321
-
6322
- const last = msg.content[msg.content.length - 1];
6323
- if (!last || last.type !== "text" || typeof last.text !== "string") return msg;
6324
- const text = last.text;
6325
- if (text.length < 80) return msg;
6326
- const isSummary = SUMMARY_PATTERNS.some((p) => p.test(text));
6327
- if (!isSummary) return msg;
6328
-
6329
- trimmed += 1;
6330
- const newContent = msg.content.slice(0, -1);
6331
- if (newContent.length === 0) return msg; // keep at least one block
6332
- return { ...msg, content: newContent };
6333
- });
6334
-
6335
- return { messages: out, changed: trimmed > 0, trimmed };
6336
- }
6337
-
6338
- function repairOrphanedToolUseBlocks(messages) {
6339
- if (!Array.isArray(messages) || messages.length === 0) return messages;
6340
-
6341
- const repaired = [];
6342
-
6343
- for (let i = 0; i < messages.length; i++) {
6344
- const msg = messages[i];
6345
- repaired.push(msg);
6346
-
6347
- // Only check assistant messages with array content
6348
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
6349
-
6350
- // Collect tool_use IDs from this assistant message
6351
- const toolUseIds = [];
6352
- for (const block of msg.content) {
6353
- if (block.type === "tool_use" && block.id) {
6354
- toolUseIds.push(block.id);
6355
- }
6356
- }
6357
- if (toolUseIds.length === 0) continue;
6358
-
6359
- // Check if the next message is a user message with matching tool_results
6360
- const next = messages[i + 1];
6361
- if (next && next.role === "user" && Array.isArray(next.content)) {
6362
- // Collect tool_result IDs present in the next user message
6363
- const resultIds = new Set();
6364
- for (const block of next.content) {
6365
- if (block.type === "tool_result" && block.tool_use_id) {
6366
- resultIds.add(block.tool_use_id);
6367
- }
6368
- }
6369
-
6370
- // Find which tool_use IDs are missing from the tool_results
6371
- const missingIds = toolUseIds.filter((id) => !resultIds.has(id));
6372
- if (missingIds.length === 0) continue; // All paired, nothing to fix
6373
-
6374
- // There are missing tool_results — inject them into the existing user message.
6375
- // Clone the next message to avoid mutating the original.
6376
- const patchedNext = {
6377
- ...next,
6378
- content: [
6379
- ...missingIds.map((id) => ({
6380
- type: "tool_result",
6381
- tool_use_id: id,
6382
- content: "[Result unavailable — tool execution was interrupted]",
6383
- })),
6384
- ...next.content,
6385
- ],
6386
- };
6387
- // Replace the next message in-place by skipping it and pushing the patched version
6388
- i++; // skip original next
6389
- repaired.push(patchedNext);
6390
- } else {
6391
- // Next message is missing or is not a user message — synthesize a full
6392
- // tool_result user message for all tool_use IDs.
6393
- repaired.push({
6394
- role: "user",
6395
- content: toolUseIds.map((id) => ({
6396
- type: "tool_result",
6397
- tool_use_id: id,
6398
- content: "[Result unavailable — tool execution was interrupted]",
6399
- })),
6400
- });
6401
- }
6402
- }
6403
-
6404
- return repaired;
6405
- }
6406
-
6407
- function stripSlashCommandMessages(messages) {
6408
- if (!Array.isArray(messages) || messages.length === 0) return messages;
6409
-
6410
- // Pattern: /anthropic followed by optional subcommand
6411
- const CMD_RE = /^\s*\/anthropic\b/i;
6412
- // Pattern: ▣ Anthropic — prefix used by all sendCommandMessage outputs
6413
- const RESP_RE = /^▣\s*Anthropic/;
6414
-
6415
- /**
6416
- * Extract the first text content from a message's content field.
6417
- * Handles both string content and array-of-blocks content.
6418
- */
6419
- function getFirstText(msg) {
6420
- if (typeof msg.content === "string") return msg.content;
6421
- if (Array.isArray(msg.content)) {
6422
- for (const block of msg.content) {
6423
- if (block.type === "text" && typeof block.text === "string") return block.text;
6424
- }
6425
- }
6426
- return "";
6427
- }
6428
-
6429
- /**
6430
- * Check if a user message is a /anthropic command.
6431
- * A message is a command if its text content starts with /anthropic.
6432
- */
6433
- function isCommandMessage(msg) {
6434
- if (msg.role !== "user") return false;
6435
- const text = getFirstText(msg);
6436
- return CMD_RE.test(text);
6437
- }
5775
+ function classifyRequestRole(parsed) {
5776
+ if (!parsed || typeof parsed !== "object") return "unknown";
5777
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
5778
+ const maxTokens = typeof parsed.max_tokens === "number" ? parsed.max_tokens : null;
6438
5779
 
6439
- /**
6440
- * Check if an assistant message is a sendCommandMessage response.
6441
- * These always start with ▣ Anthropic.
6442
- */
6443
- function isCommandResponse(msg) {
6444
- if (msg.role !== "assistant") return false;
6445
- const text = getFirstText(msg);
6446
- return RESP_RE.test(text);
5780
+ if (messages.length === 0) return "empty";
5781
+ if (maxTokens != null) {
5782
+ if (maxTokens <= 256 && messages.length <= 2) return "title";
5783
+ if (maxTokens <= 1024) return "small";
6447
5784
  }
6448
-
6449
- const filtered = [];
6450
- for (let i = 0; i < messages.length; i++) {
6451
- const msg = messages[i];
6452
-
6453
- // Drop /anthropic command messages
6454
- if (isCommandMessage(msg)) {
6455
- // Also drop the immediately following assistant response if it's a command response
6456
- if (i + 1 < messages.length && isCommandResponse(messages[i + 1])) {
6457
- i++; // Skip the response too
6458
- }
6459
- continue;
6460
- }
6461
-
6462
- // Drop orphaned command responses (in case the command message was already removed
6463
- // or the ordering is different)
6464
- if (isCommandResponse(msg)) {
6465
- continue;
5785
+ // System prompt length heuristic
5786
+ let sysLen = 0;
5787
+ if (typeof parsed.system === "string") {
5788
+ sysLen = parsed.system.length;
5789
+ } else if (Array.isArray(parsed.system)) {
5790
+ for (const s of parsed.system) {
5791
+ if (s && typeof s.text === "string") sysLen += s.text.length;
6466
5792
  }
6467
-
6468
- filtered.push(msg);
6469
5793
  }
6470
-
6471
- // Safety: if filtering removed ALL messages, return the original to avoid sending
6472
- // an empty messages array to the API.
6473
- if (filtered.length === 0) return messages;
6474
-
6475
- return filtered;
6476
- }
6477
-
6478
- function extractFirstUserMessageText(messages) {
6479
- if (!Array.isArray(messages)) return "";
6480
- for (const msg of messages) {
6481
- if (msg.role !== "user") continue;
6482
- if (typeof msg.content === "string") return msg.content;
6483
- if (Array.isArray(msg.content)) {
6484
- for (const block of msg.content) {
6485
- if (block.type === "text" && typeof block.text === "string") return block.text;
6486
- }
6487
- }
6488
- return "";
5794
+ if (sysLen < 200 && messages.length <= 2 && (maxTokens == null || maxTokens <= 2048)) {
5795
+ return "small";
6489
5796
  }
6490
- return "";
5797
+ return "main";
6491
5798
  }
6492
5799
 
6493
- const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
6494
- const _EFFORT_BETA_FLAG = "effort-2025-11-24";
6495
- const _ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
6496
- const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
6497
- const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
6498
- const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
6499
- const KNOWN_IDENTITY_STRINGS = new Set([
6500
- CLAUDE_CODE_IDENTITY_STRING,
6501
- "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
6502
- "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
6503
- ]);
6504
- const BEDROCK_UNSUPPORTED_BETAS = new Set([
6505
- "interleaved-thinking-2025-05-14",
6506
- "context-1m-2025-08-07",
6507
- "tool-search-tool-2025-10-19",
6508
- ]);
6509
- // OpenCode SDK betas that leak through the host's Anthropic SDK but are NOT
6510
- // part of CC's beta vocabulary. Filtered out when signature emulation is on.
6511
- // Core tool names (CC PascalCase) that are always eager-loaded.
6512
- const CORE_TOOL_NAMES = new Set([
6513
- "Bash",
6514
- "Read",
6515
- "Glob",
6516
- "Grep",
6517
- "Edit",
6518
- "Write",
6519
- "WebFetch",
6520
- "TodoWrite",
6521
- "Skill",
6522
- "Task",
6523
- "Compress",
6524
- ]);
6525
- const HOST_SDK_BETAS_BLOCKLIST = new Set(["fine-grained-tool-streaming-2025-05-14", "structured-outputs-2025-11-13"]);
6526
- const STAINLESS_HELPER_KEYS = [
6527
- "x_stainless_helper",
6528
- "x-stainless-helper",
6529
- "stainless_helper",
6530
- "stainlessHelper",
6531
- "_stainless_helper",
6532
- ];
6533
- const USER_ID_STORAGE_FILE = "anthropic-signature-user-id";
6534
- const DEBUG_SYSTEM_PROMPT_ENV = "OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT";
6535
- const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
6536
- const COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT = [
6537
- "You are a title generator. You output ONLY a thread title. Nothing else.",
6538
- "",
6539
- "Rules:",
6540
- "- Use the same language as the user message.",
6541
- "- Output exactly one line.",
6542
- "- Keep the title at or below 50 characters.",
6543
- "- No explanations, prefixes, or suffixes.",
6544
- "- Keep important technical terms, numbers, and filenames when present.",
6545
- ].join("\n");
6546
-
6547
5800
  /**
6548
- * Anti-verbosity system prompt text.
6549
- * Extracted from CC v2.1.100 (gated on quiet_salted_ember A/B test for Opus 4.6).
6550
- * Significantly reduces output token count by instructing the model to be concise.
5801
+ * @param {any} body
5802
+ * @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
6551
5803
  */
6552
- const ANTI_VERBOSITY_SYSTEM_PROMPT = [
6553
- "# Communication style",
6554
- "Assume users can't see most tool calls or thinking — only your text output. Before your first tool call, state in one sentence what you're about to do. While working, give short updates at key moments: when you find something, when you change direction, or when you hit a blocker. Brief is good — silent is not. One sentence per update is almost always enough.",
6555
- "",
6556
- "Don't narrate your internal deliberation. User-facing text should be relevant communication to the user, not a running commentary on your thought process. State results and decisions directly, and focus user-facing text on relevant updates for the user.",
6557
- "",
6558
- "When you do write updates, write so the reader can pick up cold: complete sentences, no unexplained jargon or shorthand from earlier in the session. But keep it tight — a clear sentence is better than a clear paragraph.",
6559
- "",
6560
- "End-of-turn summary: one or two sentences. What changed and what's next. Nothing else.",
6561
- "",
6562
- "Match responses to the task: a simple question gets a direct answer, not headers and sections.",
6563
- "",
6564
- "In code: default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks — one short line max. Don't create planning, decision, or analysis documents unless the user asks for them — work from conversation context, not intermediate files.",
6565
- ].join("\n");
6566
5804
 
6567
5805
  /**
6568
- * Numeric length anchors text.
6569
- * Extracted from CC v2.1.100. Hard word-count limits for output.
5806
+ * @param {any[]} tools
5807
+ * @param {any[]} messages
5808
+ * @returns {string}
6570
5809
  */
6571
- const NUMERIC_LENGTH_ANCHORS_PROMPT =
6572
- "Length limits: keep text between tool calls to ≤25 words. Keep final responses to ≤100 words unless the task requires more detail.";
6573
5810
 
6574
5811
  /**
6575
- * Returns the persistent device ID (64-char hex string).
6576
- * Migrates legacy UUID-format values to the new 64-hex format automatically.
5812
+ * @param {{id?: string, accountUuid?: string} | null | undefined} account
6577
5813
  * @returns {string}
6578
5814
  */
6579
- function getOrCreateDeviceId() {
6580
- const configDir = getConfigDir();
6581
- const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
6582
-
6583
- try {
6584
- if (existsSync(userIdPath)) {
6585
- const existing = readFileSync(userIdPath, "utf-8").trim();
6586
- if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
6587
- }
6588
- } catch {
6589
- // fall through and generate a new id
6590
- }
6591
-
6592
- const generated = randomBytes(32).toString("hex");
6593
- try {
6594
- mkdirSync(configDir, { recursive: true });
6595
- writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
6596
- } catch {
6597
- // Ignore filesystem errors; caller still gets generated ID for this runtime.
5815
+ function getAccountIdentifier(account) {
5816
+ // Prefer env-provided account UUID (v2.1.51+), then account record fields
5817
+ const envUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID?.trim();
5818
+ if (envUuid) return envUuid;
5819
+ if (account?.accountUuid && typeof account.accountUuid === "string") {
5820
+ return account.accountUuid;
6598
5821
  }
6599
- return generated;
6600
- }
6601
-
6602
- /**
6603
- * @returns {boolean}
6604
- */
6605
- function shouldDebugSystemPrompt() {
6606
- return isTruthyEnv(process.env[DEBUG_SYSTEM_PROMPT_ENV]);
6607
- }
6608
-
6609
- /**
6610
- * @param {string | undefined} value
6611
- * @returns {boolean}
6612
- */
6613
- function isTruthyEnv(value) {
6614
- if (!value) return false;
6615
- const normalized = value.trim().toLowerCase();
6616
- return normalized === "1" || normalized === "true" || normalized === "yes";
6617
- }
6618
-
6619
- /**
6620
- * @param {string | undefined} value
6621
- * @returns {boolean}
6622
- */
6623
- function isFalsyEnv(value) {
6624
- if (!value) return false;
6625
- const normalized = value.trim().toLowerCase();
6626
- return normalized === "0" || normalized === "false" || normalized === "no";
6627
- }
6628
-
6629
- /**
6630
- * @returns {boolean}
6631
- */
6632
- function isNonInteractiveMode() {
6633
- if (isTruthyEnv(process.env.CI)) return true;
6634
- return !process.stdout.isTTY;
6635
- }
6636
-
6637
- /**
6638
- * @returns {Record<string, string>}
6639
- */
6640
- function parseAnthropicCustomHeaders() {
6641
- const raw = process.env.ANTHROPIC_CUSTOM_HEADERS;
6642
- if (!raw) return {};
6643
-
6644
- /** @type {Record<string, string>} */
6645
- const headers = {};
6646
- for (const line of raw.split(/\r?\n/)) {
6647
- const trimmed = line.trim();
6648
- if (!trimmed) continue;
6649
- const sep = trimmed.indexOf(":");
6650
- if (sep <= 0) continue;
6651
- const key = trimmed.slice(0, sep).trim();
6652
- const value = trimmed.slice(sep + 1).trim();
6653
- if (!key || !value) continue;
6654
- headers[key] = value;
5822
+ if (account?.id && typeof account.id === "string") {
5823
+ return account.id;
6655
5824
  }
6656
-
6657
- return headers;
5825
+ return "";
6658
5826
  }
6659
5827
 
6660
5828
  /**
6661
- * @returns {string}
5829
+ * @param {{persistentUserId: string, accountId: string, sessionId: string}} input
5830
+ * @returns {{user_id: string}}
6662
5831
  */
6663
- function getClaudeEntrypoint() {
6664
- return process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
6665
- }
6666
5832
 
6667
5833
  /**
6668
- * @param {string | undefined} model
6669
- * @returns {string | undefined}
5834
+ * Return the cache_control object appropriate for the given cache policy.
5835
+ * @param {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | undefined} cachePolicy
5836
+ * @returns {{type: string, ttl?: string}}
6670
5837
  */
6671
- function logTransformedSystemPrompt(body) {
6672
- if (!shouldDebugSystemPrompt()) return;
6673
- if (!body || typeof body !== "string") return;
6674
-
6675
- try {
6676
- const parsed = JSON.parse(body);
6677
- if (!Object.prototype.hasOwnProperty.call(parsed, "system")) return;
6678
- if (isTitleGeneratorSystemBlocks(normalizeSystemTextBlocks(parsed.system))) return;
6679
- console.error(
6680
- "[opencode-anthropic-auth][system-debug] transformed system:",
6681
- JSON.stringify(parsed.system, null, 2),
6682
- );
6683
- } catch {
6684
- // Ignore parse errors in debug logging path.
5838
+ function getCacheControlForPolicy(cachePolicy) {
5839
+ if (!cachePolicy) return { type: "ephemeral" };
5840
+ if (cachePolicy.ttl === "off" || cachePolicy.ttl_supported === false) {
5841
+ return { type: "ephemeral" };
6685
5842
  }
5843
+ return { type: "ephemeral", ttl: cachePolicy.ttl };
6686
5844
  }
6687
5845
 
6688
5846
  /**
6689
- * @param {string | undefined} body
6690
- * @returns {boolean}
6691
- */
6692
- function isHaikuModel(model) {
6693
- return /haiku/i.test(model);
6694
- }
6695
-
6696
- /**
6697
- * @param {string | undefined} body
6698
- * @returns {boolean}
6699
- */
6700
- function supportsThinking(model) {
6701
- if (!model) return true;
6702
- return /claude|sonnet|opus|haiku/i.test(model);
6703
- }
6704
-
6705
- /**
6706
- * Detects claude-opus-4.6 / claude-opus-4-6 model IDs.
6707
- * These models use adaptive thinking (effort parameter) instead of
6708
- * manual budgetTokens.
6709
- * @param {string | undefined} body
6710
- * @returns {boolean}
6711
- */
6712
- function isOpus46Model(model) {
6713
- if (!model) return false;
6714
- // Match standard IDs (claude-opus-4-6, claude-opus-4.6) and Bedrock ARNs
6715
- // (arn:aws:bedrock:...anthropic.claude-opus-4-6-...).
6716
- // Also match bare "opus-4-6" / "opus-4.6" fragments for non-standard strings.
6717
- return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
6718
- }
6719
-
6720
- /**
6721
- * Detects claude-opus-4.7 / claude-opus-4-7 model IDs.
6722
- * @param {string | undefined} model
6723
- * @returns {boolean}
6724
- */
6725
- function isOpus47Model(model) {
6726
- if (!model) return false;
6727
- return /claude-opus-4[._-]7|opus[._-]4[._-]7/i.test(model);
6728
- }
6729
-
6730
- /**
6731
- * Detects claude-opus-4.8 / claude-opus-4-8 model IDs.
6732
- * Opus 4.8 (launched 2026-05-28) is an adaptive-thinking model: manual
6733
- * `thinking: {type: "enabled", budget_tokens}` returns a 400 — it MUST use
6734
- * `thinking: {type: "adaptive"}` + the effort parameter. It also supports
6735
- * `speed: "fast"` (fast-mode research preview) and 1M context by default.
6736
- * @param {string | undefined} model
6737
- * @returns {boolean}
6738
- */
6739
- function isOpus48Model(model) {
6740
- if (!model) return false;
6741
- return /claude-opus-4[._-]8|opus[._-]4[._-]8/i.test(model);
6742
- }
6743
-
6744
- /**
6745
- * Models eligible for the "simple system prompt" mode that real CC ships in
6746
- * v2.1.133+ under GrowthBook flag `tengu_vellum_lantern` (or forced via
6747
- * `CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT=1`).
6748
- *
6749
- * Real CC's `sR9` matches: `claude-opus-4-7`, any model with `-eap` (early
6750
- * access) suffix, plus a small GrowthBook-controlled allowlist we can't read.
6751
- * Plugin mirrors the deterministic subset only.
6752
- *
6753
- * @param {string | undefined} model
6754
- * @returns {boolean}
6755
- */
6756
- function isSimpleSystemPromptEligible(model) {
6757
- if (!model) return false;
6758
- if (isOpus47Model(model) || isOpus48Model(model)) return true;
6759
- // -eap suffix variant, e.g. "claude-opus-4-7-eap" or "claude-opus-4-7-eap[1m]"
6760
- if (/-eap(?:\[|$|-)/i.test(model)) return true;
6761
- return false;
6762
- }
6763
-
6764
- /**
6765
- * Detects claude-sonnet-4.6 / claude-sonnet-4-6 model IDs.
6766
- * @param {string | undefined} body
6767
- * @returns {boolean}
6768
- */
6769
- function isSonnet46Model(model) {
6770
- if (!model) return false;
6771
- return /claude-sonnet-4[._-]6|sonnet[._-]4[._-]6/i.test(model);
6772
- }
6773
-
6774
- /**
6775
- * Detects models that support adaptive thinking ({type: "adaptive"}).
6776
- * Currently: Opus 4.6, Opus 4.7, Opus 4.8, and Sonnet 4.6.
6777
- * @param {string | undefined} body
6778
- * @returns {boolean}
6779
- */
6780
- export function isFable5Model(model) {
6781
- if (!model) return false;
6782
- return /claude-fable-5|fable[._-]5/i.test(model);
6783
- }
6784
-
6785
- /**
6786
- * @param {string | undefined} model
6787
- * @returns {boolean}
6788
- */
6789
- export function isMythos5Model(model) {
6790
- if (!model) return false;
6791
- return /claude-mythos-5|mythos[._-]5/i.test(model);
6792
- }
6793
-
6794
- /**
6795
- * @param {string | undefined} model
6796
- * @returns {boolean}
6797
- */
6798
- export function isAdaptiveThinkingModel(model) {
6799
- return (
6800
- isOpus46Model(model) ||
6801
- isOpus47Model(model) ||
6802
- isOpus48Model(model) ||
6803
- isSonnet46Model(model) ||
6804
- isFable5Model(model) ||
6805
- isMythos5Model(model)
6806
- );
6807
- }
6808
-
6809
- /**
6810
- * Check if a model is eligible for 1M context (can receive context-1m beta).
6811
- * Real CC v2.1.97 U01(): claude-sonnet-4* || opus-4-6 are eligible.
6812
- * Also matches explicit "1m" in the name (e.g. "claude-opus-4-6[1m]").
6813
- * @param {string} model
6814
- * @returns {boolean}
6815
- */
6816
- function isEligibleFor1MContext(model) {
6817
- if (!model) return false;
6818
- // Explicit 1m suffix/tag in model name
6819
- if (/(^|[-_ ])1m($|[-_ ])|context[-_]?1m|\[1m\]/i.test(model)) return true;
6820
- // CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6.
6821
- // Opus 4.7 / 4.8 (successors to 4.6) are also 1M-context eligible.
6822
- // Opus 4.8 ships with 1M context by default on the Claude API.
6823
- return (
6824
- /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model)
6825
- );
6826
- }
6827
-
6828
- /**
6829
- * Check if a model should ALWAYS use 1M context (static mode, no adaptive gating).
6830
- * Only models with explicit "1m" in the name — NOT bare Opus 4.6.
6831
- * When adaptive_context is enabled, Opus 4.6 uses the adaptive decision instead.
6832
- * @param {string} model
6833
- * @returns {boolean}
6834
- */
6835
- function hasOneMillionContext(model) {
6836
- return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model);
6837
- }
6838
-
6839
- /**
6840
- * @param {string | undefined} body
6841
- * @returns {boolean}
6842
- */
6843
- function supportsStructuredOutputs(model) {
6844
- if (!/claude|sonnet|opus|haiku/i.test(model)) return false;
6845
- return !isHaikuModel(model);
6846
- }
6847
-
6848
- /**
6849
- * @param {string | undefined} body
6850
- * @returns {boolean}
6851
- */
6852
- function supportsWebSearch(model) {
6853
- return /claude|sonnet|opus|haiku|gpt|gemini/i.test(model);
6854
- }
6855
-
6856
- /**
6857
- * @param {URL | null} requestUrl
6858
- * @returns {"anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle"}
6859
- */
6860
- function detectProvider(requestUrl) {
6861
- // Match Claude Code provider precedence first (env-driven), then URL fallback.
6862
- if (isTruthyEnv(process.env.CLAUDE_CODE_USE_BEDROCK)) return "bedrock";
6863
- if (isTruthyEnv(process.env.CLAUDE_CODE_USE_FOUNDRY)) return "foundry";
6864
- if (isTruthyEnv(process.env.CLAUDE_CODE_USE_ANTHROPIC_AWS)) return "anthropicAws";
6865
- if (isTruthyEnv(process.env.CLAUDE_CODE_USE_MANTLE)) return "mantle";
6866
- if (isTruthyEnv(process.env.CLAUDE_CODE_USE_VERTEX)) return "vertex";
6867
-
6868
- if (!requestUrl) return "anthropic";
6869
- const host = requestUrl.hostname.toLowerCase();
6870
- if (host.includes("mantle")) return "mantle";
6871
- if (host.includes("anthropicaws")) return "anthropicAws";
6872
- if (host.includes("bedrock") || host.includes("amazonaws.com")) return "bedrock";
6873
- if (host.includes("aiplatform") || host.includes("vertex")) return "vertex";
6874
- if (host.includes("foundry") || host.includes("azure")) return "foundry";
6875
- return "anthropic";
6876
- }
6877
-
6878
- /**
6879
- * Classify a request by inferred role, mirroring CC's `querySource` gate.
6880
- * CC gates features like context-hint on `querySource.startsWith("repl_main_thread")`.
6881
- * We don't have that string on the wire, so we infer from body shape.
6882
- *
6883
- * Returns one of:
6884
- * - "main" → interactive main thread (long system, normal max_tokens, messages present)
6885
- * - "title" → title / name generation (tiny max_tokens, 1 message)
6886
- * - "small" → short background query (small max_tokens but not title)
6887
- * - "empty" → pre-warm / no messages
6888
- * - "unknown" → treat as main for safety
6889
- *
6890
- * @param {any} parsed Parsed request body
6891
- * @returns {"main"|"title"|"small"|"empty"|"unknown"}
6892
- */
6893
- function classifyRequestRole(parsed) {
6894
- if (!parsed || typeof parsed !== "object") return "unknown";
6895
- const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
6896
- const maxTokens = typeof parsed.max_tokens === "number" ? parsed.max_tokens : null;
6897
-
6898
- if (messages.length === 0) return "empty";
6899
- if (maxTokens != null) {
6900
- if (maxTokens <= 256 && messages.length <= 2) return "title";
6901
- if (maxTokens <= 1024) return "small";
6902
- }
6903
- // System prompt length heuristic
6904
- let sysLen = 0;
6905
- if (typeof parsed.system === "string") {
6906
- sysLen = parsed.system.length;
6907
- } else if (Array.isArray(parsed.system)) {
6908
- for (const s of parsed.system) {
6909
- if (s && typeof s.text === "string") sysLen += s.text.length;
6910
- }
6911
- }
6912
- if (sysLen < 200 && messages.length <= 2 && (maxTokens == null || maxTokens <= 2048)) {
6913
- return "small";
6914
- }
6915
- return "main";
6916
- }
6917
-
6918
- /**
6919
- * @param {any} body
6920
- * @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
6921
- */
6922
- function parseRequestBodyMetadata(body, parsedBody) {
6923
- const parsed =
6924
- parsedBody ||
6925
- (typeof body === "string"
6926
- ? (() => {
6927
- try {
6928
- return JSON.parse(body);
6929
- } catch {
6930
- return null;
6931
- }
6932
- })()
6933
- : null);
6934
- if (!parsed) {
6935
- return { model: "", tools: [], messages: [], hasFileReferences: false };
6936
- }
6937
-
6938
- const model = typeof parsed?.model === "string" ? parsed.model : "";
6939
- const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
6940
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
6941
- const hasFileReferences = extractFileIds(parsed).length > 0;
6942
- return { model, tools, messages, hasFileReferences };
6943
- }
6944
-
6945
- /**
6946
- * @param {any[]} tools
6947
- * @param {any[]} messages
6948
- * @returns {string}
6949
- */
6950
- function buildStainlessHelperHeader(tools, messages) {
6951
- const helpers = new Set();
6952
-
6953
- const collect = (value) => {
6954
- if (!value || typeof value !== "object") return;
6955
-
6956
- for (const key of STAINLESS_HELPER_KEYS) {
6957
- if (typeof value[key] === "string" && value[key]) {
6958
- helpers.add(value[key]);
6959
- }
6960
- }
6961
-
6962
- if (Array.isArray(value.content)) {
6963
- for (const contentBlock of value.content) {
6964
- collect(contentBlock);
6965
- }
6966
- }
6967
- };
6968
-
6969
- for (const tool of tools) collect(tool);
6970
- for (const message of messages) collect(message);
6971
-
6972
- return Array.from(helpers).join(", ");
6973
- }
6974
-
6975
- /**
6976
- * @param {{id?: string, accountUuid?: string} | null | undefined} account
6977
- * @returns {string}
6978
- */
6979
- function getAccountIdentifier(account) {
6980
- // Prefer env-provided account UUID (v2.1.51+), then account record fields
6981
- const envUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID?.trim();
6982
- if (envUuid) return envUuid;
6983
- if (account?.accountUuid && typeof account.accountUuid === "string") {
6984
- return account.accountUuid;
6985
- }
6986
- if (account?.id && typeof account.id === "string") {
6987
- return account.id;
6988
- }
6989
- return "";
6990
- }
6991
-
6992
- /**
6993
- * @param {{persistentUserId: string, accountId: string, sessionId: string}} input
6994
- * @returns {{user_id: string}}
6995
- */
6996
- function buildRequestMetadata(input) {
6997
- // Backward-compat override: raw user_id passed through without JSON-encoding.
6998
- const envUserId = process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
6999
- if (envUserId) return { user_id: envUserId };
7000
-
7001
- const extraMetadataEnv = process.env.CLAUDE_CODE_EXTRA_METADATA?.trim();
7002
- let extraMetadata = {};
7003
- if (extraMetadataEnv) {
7004
- try {
7005
- const parsed = JSON.parse(extraMetadataEnv);
7006
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
7007
- extraMetadata = parsed;
7008
- }
7009
- } catch {
7010
- /* ignore */
7011
- }
7012
- }
7013
-
7014
- return {
7015
- user_id: JSON.stringify({
7016
- ...extraMetadata,
7017
- device_id: input.persistentUserId,
7018
- account_uuid: input.accountId,
7019
- session_id: input.sessionId,
7020
- }),
7021
- };
7022
- }
7023
-
7024
- /**
7025
- * Build the billing header block for Claude Code system prompt injection.
7026
- * Claude Code v2.1.97: cc_version includes 3-char fingerprint hash (not model ID).
7027
- * cch is a static "00000" placeholder (xxHash64 attestation removed in v2.1.97).
7028
- *
7029
- * Real CC (system.ts:78): version = `${MACRO.VERSION}.${fingerprint}`
7030
- * Real CC (system.ts:82): cch = ' cch=00000;' (static, no longer computed)
7031
- *
7032
- * @param {string} version - CLI version (e.g., "2.1.97")
7033
- * @param {string} [firstUserMessage] - First user message text for fingerprint computation
7034
- * @param {string} [provider] - API provider ("anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle")
7035
- * @param {string} [workloadOverride] - Explicit workload tag from config (`signature_emulation.workload`).
7036
- * Takes precedence over `CLAUDE_CODE_WORKLOAD` env var. Mirrors real CC's `--workload <tag>` flag.
7037
- * @returns {string}
7038
- */
7039
- function buildAnthropicBillingHeader(version, firstUserMessage, provider, workloadOverride) {
7040
- if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
7041
- // Real CC sends cc_entrypoint=cli (confirmed via proxy capture).
7042
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
7043
- // Fix #1: cc_version suffix is the 3-char fingerprint hash, NOT the model ID.
7044
- // computeBillingCacheHash() computes SHA256(salt + msg[4]+msg[7]+msg[20] + version)[:3]
7045
- // which matches computeFingerprint() in the real CC source (utils/fingerprint.ts).
7046
- // Always call the hash function — even for empty messages the real CC computes
7047
- // the hash from "000" chars (indices 4,7,20 all missing → fallback "0").
7048
- const fingerprint = computeBillingCacheHash(firstUserMessage || "", version);
7049
- const ccVersion = `${version}.${fingerprint}`;
7050
- // cch: v2.1.97 sends static "cch=00000" — xxHash64 attestation was removed.
7051
- // The server uses the PRESENCE of cch=00000 as a CC identification signal.
7052
- const cchDisabled = provider === "bedrock" || provider === "anthropicAws" || provider === "mantle";
7053
- const cchPart = cchDisabled ? "" : " cch=00000;";
7054
- // Build workload part (upstream concatenates directly, no regex replace).
7055
- // Config wins over env so `signature_emulation.workload` is portable across hosts.
7056
- let workloadPart = "";
7057
- const workload = workloadOverride || process.env.CLAUDE_CODE_WORKLOAD;
7058
- if (workload && !cchDisabled) {
7059
- // QA fix M5: sanitize workload value to prevent header injection.
7060
- // Real CC skips cc_workload on bedrock/anthropicAws/mantle (same gate as cch).
7061
- const safeWorkload = String(workload).replace(/[;\s\r\n]/g, "_");
7062
- if (safeWorkload) workloadPart = ` cc_workload=${safeWorkload};`;
7063
- }
7064
- return `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=${entrypoint};${cchPart}${workloadPart}`;
7065
- }
7066
-
7067
- /**
7068
- * @param {string} text
7069
- * @returns {string}
7070
- */
7071
- // Max system prompt length that passes CC billing validation.
7072
- // The server pattern-matches the system prompt against the real CC prompt.
7073
- // Opencode's customizations after ~5800 chars diverge and trigger extra usage billing.
7074
- const MAX_SAFE_SYSTEM_TEXT_LENGTH = 5000;
7075
-
7076
- // A5: Subagent CC-prefix cache.
7077
- //
7078
- // Context: opencode/packages/opencode/src/session/llm.ts:110 uses
7079
- // `input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(model)`
7080
- // so any agent with a custom prompt (explore, fast, title, summary, etc.)
7081
- // fires WITHOUT the base CC prompt — the server-side fingerprint match fails
7082
- // and the request is billed as pay-as-you-go credits instead of Max-plan usage.
7083
- //
7084
- // Fix: on the first main-agent call (where the anchor is present), cache the
7085
- // sanitized CC prefix. On subsequent subagent calls (anchor missing), prepend
7086
- // the cached prefix to the sanitized blocks so the fingerprint matches again.
7087
- //
7088
- // The cache lives at module scope because buildSystemPromptBlocks is re-entered
7089
- // per request. It gets populated exactly once per process on the first main call.
7090
- const MAX_SUBAGENT_CC_PREFIX = MAX_SAFE_SYSTEM_TEXT_LENGTH;
7091
- const SUBAGENT_CC_ANCHOR = "You are an interactive";
7092
- let cachedCCPrompt = null;
7093
-
7094
- // Perf: module-scope regexes reused across per-request hot paths. None use the
7095
- // `/g` flag and all are consumed via `.test()`, so a single shared instance is
7096
- // stateless and safe (no `lastIndex` to reset between calls).
7097
- const CLAUDE_3_MODEL_RE = /claude-3-/i;
7098
- const TAIL_IMPORTANT_RE = /\b(MUST|NEVER|CRITICAL|IMPORTANT|REQUIRED|DO NOT|ALWAYS|FORBIDDEN)\b/i;
7099
- const TAIL_HEADER_RE = /^#{1,4}\s/;
7100
- const TAIL_LIST_ITEM_RE = /^\s*[-*]\s/;
7101
-
7102
- function sanitizeSystemText(text) {
7103
- // QA fix M4: use word boundaries to avoid mangling URLs and code identifiers
7104
- let sanitized = text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude");
7105
- // Strip non-CC custom prefixes before the standard CC prompt.
7106
- const ccStandardStart = sanitized.indexOf("You are an interactive");
7107
- if (ccStandardStart > 0) {
7108
- sanitized = sanitized.slice(ccStandardStart);
7109
- }
7110
- // NOTE: truncation removed — real CC v2.1.107 sends 26K+ char system prompts.
7111
- // The server checks for CC identity/billing markers, not exact prompt length.
7112
- return sanitized;
7113
- }
7114
-
7115
- /**
7116
- * @param {string} text
7117
- * @param {'minimal' | 'off'} mode
7118
- * @returns {string}
7119
- */
7120
- function tailSystemBlock(text, maxChars, turnThreshold) {
7121
- const lines = text.split("\n");
7122
- const kept = [];
7123
- let charCount = 0;
7124
- const importantRe = TAIL_IMPORTANT_RE;
7125
- const headerRe = TAIL_HEADER_RE;
7126
- const listItemRe = TAIL_LIST_ITEM_RE;
7127
- // Always keep the first paragraph (identity/role definition)
7128
- let firstParaEnd = 0;
7129
- for (let j = 0; j < lines.length; j++) {
7130
- if (lines[j].trim() === "" && j > 0) {
7131
- firstParaEnd = j;
7132
- break;
7133
- }
7134
- }
7135
- if (firstParaEnd === 0) firstParaEnd = Math.min(5, lines.length);
7136
- for (let j = 0; j <= firstParaEnd; j++) {
7137
- kept.push(lines[j]);
7138
- charCount += (lines[j]?.length || 0) + 1;
7139
- }
7140
- // Scan remaining lines: keep headers, important constraints, short list items
7141
- for (let j = firstParaEnd + 1; j < lines.length; j++) {
7142
- const line = lines[j];
7143
- const isHeader = headerRe.test(line);
7144
- const isImportant = importantRe.test(line);
7145
- const isShortListItem = listItemRe.test(line) && line.length < 120;
7146
- if (isHeader || isImportant || isShortListItem) {
7147
- if (charCount + line.length + 1 > maxChars) break;
7148
- kept.push(line);
7149
- charCount += line.length + 1;
7150
- }
7151
- }
7152
- kept.push("", "[Verbose instructions trimmed after turn " + turnThreshold + ". Key constraints preserved above.]");
7153
- return kept.join("\n");
7154
- }
7155
-
7156
- function compactToolDescription(text) {
7157
- return text
7158
- .replace(/<example[\s\S]*?<\/example>/gi, "")
7159
- .replace(/\|[\s|:-]+\|/g, "")
7160
- .replace(/^\|.*\|$/gm, "")
7161
- .replace(/^(?:\s*[-*]\s+.{200,})$/gm, (m) => m.slice(0, 200) + "...")
7162
- .replace(/^(#{1,3}\s+)/gm, "")
7163
- .replace(/\*\*([^*]+)\*\*/g, "$1")
7164
- .replace(/`([^`]+)`/g, "$1")
7165
- .replace(/\n{3,}/g, "\n\n")
7166
- .trim();
7167
- }
7168
-
7169
- function compactSystemText(text, mode) {
7170
- const withoutDuplicateIdentityPrefix = text.startsWith(`${CLAUDE_CODE_IDENTITY_STRING}\n`)
7171
- ? text.slice(CLAUDE_CODE_IDENTITY_STRING.length).trimStart()
7172
- : text;
7173
-
7174
- if (mode === "off") {
7175
- return withoutDuplicateIdentityPrefix.trim();
7176
- }
7177
-
7178
- const compacted = withoutDuplicateIdentityPrefix.replace(/<example>[\s\S]*?<\/example>/gi, "\n");
7179
-
7180
- const dedupedLines = [];
7181
- let prevNormalized = "";
7182
- for (const line of compacted.split("\n")) {
7183
- const normalized = line.trim();
7184
- if (normalized && normalized === prevNormalized) continue;
7185
- dedupedLines.push(line);
7186
- prevNormalized = normalized;
7187
- }
7188
-
7189
- return dedupedLines
7190
- .join("\n")
7191
- .replace(/\n{3,}/g, "\n\n")
7192
- .trim();
7193
- }
7194
-
7195
- /**
7196
- * @param {string} text
7197
- * @returns {string}
7198
- */
7199
- function normalizeSystemTextForComparison(text) {
7200
- return text
7201
- .replace(/\r\n/g, "\n")
7202
- .split("\n")
7203
- .map((line) => line.trim())
7204
- .join("\n")
7205
- .replace(/\n{3,}/g, "\n\n")
7206
- .trim();
7207
- }
7208
-
7209
- /**
7210
- * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
7211
- * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
7212
- */
7213
- function dedupeSystemBlocks(system) {
7214
- const exactSeen = new Set();
7215
- const exactDeduped = [];
7216
-
7217
- for (const item of system) {
7218
- const normalized = normalizeSystemTextForComparison(item.text);
7219
- const key = `${item.type}:${normalized}`;
7220
- if (exactSeen.has(key)) continue;
7221
- exactSeen.add(key);
7222
- exactDeduped.push(item);
7223
- }
7224
-
7225
- const normalizedBlocks = exactDeduped.map((item) => normalizeSystemTextForComparison(item.text));
7226
- return exactDeduped.filter((_, index) => {
7227
- const current = normalizedBlocks[index];
7228
- if (current.length < 80) return true;
7229
-
7230
- for (let otherIndex = 0; otherIndex < normalizedBlocks.length; otherIndex += 1) {
7231
- if (otherIndex === index) continue;
7232
- const other = normalizedBlocks[otherIndex];
7233
- if (other.length <= current.length + 20) continue;
7234
- if (other.includes(current)) return false;
7235
- }
7236
-
7237
- return true;
7238
- });
7239
- }
7240
-
7241
- /**
7242
- * @param {string} text
7243
- * @returns {boolean}
7244
- */
7245
- function isTitleGeneratorSystemText(text) {
7246
- const normalized = text.trim().toLowerCase();
7247
- return normalized.includes("you are a title generator") || normalized.includes("generate a brief title");
7248
- }
7249
-
7250
- /**
7251
- * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
7252
- * @returns {boolean}
7253
- */
7254
- function isTitleGeneratorSystemBlocks(system) {
7255
- return system.some(
7256
- (item) => item.type === "text" && typeof item.text === "string" && isTitleGeneratorSystemText(item.text),
7257
- );
7258
- }
7259
-
7260
- /**
7261
- * @param {any[] | undefined} system
7262
- * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
7263
- */
7264
- // Env-context fingerprint rewrite: opencode emits
7265
- // "Here is some useful information about the environment you are running in:"
7266
- // Real CC 2.1.117 emits
7267
- // "Here is useful information about the environment you are running in:"
7268
- // Removing "some " gives byte-level parity with the CC fingerprint and saves
7269
- // ~1 token in the non-cached portion. Unconditional, every request.
7270
- const OPENCODE_ENV_CONTEXT_PREFIX = "Here is some useful information about the environment you are running in:";
7271
- const CC_ENV_CONTEXT_PREFIX = "Here is useful information about the environment you are running in:";
7272
-
7273
- function rewriteEnvContextPhrasing(text) {
7274
- if (typeof text !== "string" || text.length === 0) return text;
7275
- if (text.indexOf(OPENCODE_ENV_CONTEXT_PREFIX) === -1) return text;
7276
- return text.split(OPENCODE_ENV_CONTEXT_PREFIX).join(CC_ENV_CONTEXT_PREFIX);
7277
- }
7278
-
7279
- function normalizeSystemTextBlocks(system) {
7280
- const output = [];
7281
- if (!Array.isArray(system)) return output;
7282
-
7283
- for (const item of system) {
7284
- if (typeof item === "string") {
7285
- output.push({ type: "text", text: rewriteEnvContextPhrasing(item) });
7286
- continue;
7287
- }
7288
-
7289
- if (!item || typeof item !== "object") continue;
7290
- if (typeof item.text !== "string") continue;
7291
-
7292
- const normalized = {
7293
- type: typeof item.type === "string" ? item.type : "text",
7294
- text: rewriteEnvContextPhrasing(item.text),
7295
- };
7296
-
7297
- // Intentionally strip cache_control from incoming system blocks.
7298
- // The plugin controls cache placement: only the identity block and
7299
- // boundary-split blocks get cache_control (added in buildSystemPromptBlocks).
7300
- // Passing through upstream markers can cause "maximum of 4 blocks with
7301
- // cache_control" API errors when combined with our own markers.
7302
-
7303
- output.push(normalized);
7304
- }
7305
-
7306
- return output;
7307
- }
7308
-
7309
- /**
7310
- * Return the cache_control object appropriate for the given cache policy.
7311
- * @param {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | undefined} cachePolicy
7312
- * @returns {{type: string, ttl?: string}}
7313
- */
7314
- function getCacheControlForPolicy(cachePolicy) {
7315
- if (!cachePolicy) return { type: "ephemeral" };
7316
- if (cachePolicy.ttl === "off" || cachePolicy.ttl_supported === false) {
7317
- return { type: "ephemeral" };
7318
- }
7319
- return { type: "ephemeral", ttl: cachePolicy.ttl };
7320
- }
7321
-
7322
- /**
7323
- * Determine the identity string prefix, matching real CC's getCLISyspromptPrefix().
7324
- * Real CC selects based on isNonInteractive + hasAppendSystemPrompt flags.
7325
- * OpenCode is always interactive CLI, so DEFAULT_PREFIX is almost always correct.
7326
- * We check for Agent SDK signals from the environment to match non-interactive cases.
7327
- *
7328
- * @returns {string}
7329
- */
7330
- function getCLISyspromptPrefix() {
7331
- // Agent SDK preset: when running within the Claude Agent SDK with CC preset
7332
- if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
7333
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "";
7334
- if (entrypoint === "agent-sdk" || entrypoint === "sdk") {
7335
- return "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.";
7336
- }
7337
- }
7338
- // Non-interactive agent without CC preset
7339
- if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && !isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
7340
- return "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
7341
- }
7342
- return CLAUDE_CODE_IDENTITY_STRING;
7343
- }
7344
-
7345
- /**
7346
- * Compute the cache_control object for a given cache scope and policy.
7347
- * Mirrors real CC getCacheControl() (src/services/api/claude.ts:358-374).
7348
- *
7349
- * Real CC behavior:
7350
- * - scope 'global' → {type: 'ephemeral', scope: 'global', ttl?: '1h'}
7351
- * - scope 'org' → {type: 'ephemeral', ttl?: '1h'} (org is internal, NOT on wire)
7352
- * - scope null → block gets NO cache_control at all (caller should omit it)
7353
- *
7354
- * @param {'global' | 'org' | null} cacheScope
7355
- * @param {{ttl: string, ttl_supported: boolean}} cachePolicy
7356
- * @returns {{type: string, ttl?: string, scope?: string} | null} null means "no cache_control"
7357
- */
7358
- function getCacheControlForScope(cacheScope, cachePolicy) {
7359
- if (cacheScope === null) return null; // no cache_control for this block
7360
-
7361
- const hasTtl = cachePolicy.ttl !== "off" && cachePolicy.ttl_supported !== false;
7362
- const result = { type: "ephemeral" };
7363
- if (hasTtl) result.ttl = cachePolicy.ttl;
7364
- // Only 'global' scope is emitted on the wire; 'org' is internal-only
7365
- if (cacheScope === "global") result.scope = "global";
7366
- return result;
7367
- }
7368
-
7369
- /**
7370
- * Split system prompt blocks into structured blocks with cache scoping,
7371
- * matching real CC splitSysPromptPrefix() (src/utils/api.ts:321-435).
7372
- *
7373
- * Real CC has 3 paths that produce 2 distinct wire formats:
7374
- *
7375
- * Path A (tool-based cache): skipGlobalCacheForSystemPrompt=true
7376
- * - billing → cacheScope: null
7377
- * - identity → cacheScope: 'org'
7378
- * - rest (joined) → cacheScope: 'org'
7379
- * Wire result: identical to Path C (org → ephemeral without scope field)
7380
- *
7381
- * Path B (boundary mode): shouldUseGlobalCacheScope() && boundary marker found
7382
- * - billing → cacheScope: null
7383
- * - identity → cacheScope: null (NO cache_control in boundary mode!)
7384
- * - static blocks (before boundary, joined) → cacheScope: 'global'
7385
- * - dynamic blocks (after boundary, joined) → cacheScope: null
7386
- *
7387
- * Path C (fallback): no global cache feature or no boundary
7388
- * - billing → cacheScope: null
7389
- * - identity → cacheScope: 'org'
7390
- * - rest (joined) → cacheScope: 'org'
7391
- *
7392
- * @param {Array<{text: string}>} blocks - Already sanitized/filtered text blocks
7393
- * @param {string | undefined} attributionHeader - The billing header text (or undefined)
7394
- * @param {string} identityString - The identity prefix string
7395
- * @param {boolean} useBoundaryMode - Whether to use Path B (global cache with boundary)
7396
- * @returns {Array<{text: string, cacheScope: 'global' | 'org' | null}>}
7397
- */
7398
- function splitSysPromptPrefix(blocks, attributionHeader, identityString, useBoundaryMode) {
7399
- // Separate known blocks from rest, matching real CC's parsing loop
7400
- const rest = [];
7401
- for (const block of blocks) {
7402
- if (!block.text) continue;
7403
- // Skip if it's a billing header or identity string (already extracted)
7404
- if (block.text.startsWith("x-anthropic-billing-header:")) continue;
7405
- if (KNOWN_IDENTITY_STRINGS.has(block.text)) continue;
7406
- // Skip the boundary marker itself (real CC skips it in Path A, processes it in Path B)
7407
- if (block.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
7408
- rest.push(block.text);
7409
- }
7410
-
7411
- // ====================================================================
7412
- // Path B: Global cache with boundary marker
7413
- // Real CC (utils/api.ts:219-262): when shouldUseGlobalCacheScope() &&
7414
- // boundary marker is found in the system prompt array.
7415
- // ====================================================================
7416
- if (useBoundaryMode) {
7417
- // Find boundary marker in the ORIGINAL block array (before filtering)
7418
- const boundaryIndex = blocks.findIndex((b) => b.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY);
7419
-
7420
- if (boundaryIndex !== -1) {
7421
- // Classify blocks as static (before boundary) or dynamic (after boundary)
7422
- const staticBlocks = [];
7423
- const dynamicBlocks = [];
7424
- for (let i = 0; i < blocks.length; i++) {
7425
- const text = blocks[i].text;
7426
- if (!text) continue;
7427
- if (text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
7428
- if (text.startsWith("x-anthropic-billing-header:")) continue;
7429
- if (KNOWN_IDENTITY_STRINGS.has(text)) continue;
7430
- if (i < boundaryIndex) {
7431
- staticBlocks.push(text);
7432
- } else {
7433
- dynamicBlocks.push(text);
7434
- }
7435
- }
7436
-
7437
- const result = [];
7438
- if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
7439
- // Identity: cacheScope null in boundary mode (real CC behavior)
7440
- result.push({ text: identityString, cacheScope: null });
7441
- const staticJoined = staticBlocks.join("\n");
7442
- if (staticJoined) result.push({ text: staticJoined, cacheScope: "global" });
7443
- const dynamicJoined = dynamicBlocks.join("\n");
7444
- if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null });
7445
- return result;
7446
- }
7447
- // Boundary marker not found — fall through to Path C
7448
- }
7449
-
7450
- // ====================================================================
7451
- // Path C (fallback) / Path A (tool-based): no boundary or no global cache
7452
- // Real CC (utils/api.ts:264-289): identity and rest get cacheScope 'org'
7453
- // Path A produces identical wire output to Path C.
7454
- // ====================================================================
7455
- const result = [];
7456
- if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
7457
- result.push({ text: identityString, cacheScope: "org" });
7458
- const restJoined = rest.join("\n");
7459
- if (restJoined) result.push({ text: restJoined, cacheScope: "org" });
7460
- return result;
7461
- }
7462
-
7463
- /**
7464
- * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
7465
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
7466
- * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
7467
- */
7468
- function buildSystemPromptBlocks(system, signature) {
7469
- const titleGeneratorRequest = isTitleGeneratorSystemBlocks(system);
7470
-
7471
- let sanitized = system.map((item) => ({
7472
- ...item,
7473
- text: compactSystemText(sanitizeSystemText(item.text), signature.promptCompactionMode),
7474
- }));
7475
-
7476
- // A5: Subagent CC-prefix cache/inject (see constant declaration above for context).
7477
- //
7478
- // After sanitize, main-agent blocks start with "You are an interactive..." because
7479
- // sanitizeSystemText() strips everything before that anchor. Subagent blocks
7480
- // (custom prompts from input.agent.prompt) do NOT start with the anchor —
7481
- // they start with whatever the agent template says (e.g., "You are a file search
7482
- // specialist.").
7483
- //
7484
- // This logic runs ONLY for Anthropic requests with signature enabled (signature.enabled
7485
- // is false for non-Anthropic providers), and skips the title-generator fast path
7486
- // because that one is replaced wholesale with COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT below.
7487
- if (signature.enabled && !titleGeneratorRequest && sanitized.length > 0) {
7488
- const firstText = typeof sanitized[0]?.text === "string" ? sanitized[0].text : "";
7489
- const hasCcAnchor = firstText.startsWith(SUBAGENT_CC_ANCHOR);
7490
-
7491
- if (hasCcAnchor) {
7492
- // Main-agent path: cache the prefix on the first hit so subagents can reuse it.
7493
- // We slice to MAX_SUBAGENT_CC_PREFIX to avoid unbounded growth if the upstream
7494
- // sanitize limit is ever raised.
7495
- if (!cachedCCPrompt) {
7496
- cachedCCPrompt = firstText.slice(0, MAX_SUBAGENT_CC_PREFIX);
7497
- }
7498
- } else if (cachedCCPrompt) {
7499
- // Subagent path: prepend the cached CC prefix so the fingerprint matches.
7500
- // We prepend, not concatenate, so the original subagent prompt stays as a
7501
- // separate block — dedupeSystemBlocks and splitSysPromptPrefix handle the
7502
- // join on their own downstream.
7503
- sanitized = [{ type: "text", text: cachedCCPrompt }, ...sanitized];
7504
- }
7505
- // If !hasCcAnchor && !cachedCCPrompt: no-op. The cache primes on the very
7506
- // first main call in a process. In practice opencode always fires a main
7507
- // call before any subagent, so this branch is only hit in synthetic tests.
7508
- }
7509
-
7510
- if (titleGeneratorRequest) {
7511
- sanitized = [{ type: "text", text: COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT }];
7512
- } else if (signature.promptCompactionMode !== "off") {
7513
- sanitized = dedupeSystemBlocks(sanitized);
7514
- }
7515
-
7516
- // Anti-verbosity injection (CC v2.1.100 quiet_salted_ember equivalent).
7517
- // Applies to Opus 4.6 / 4.7 for non-title-generator requests.
7518
- //
7519
- // Simple-system-prompt gate (CC v2.1.133+ `tengu_vellum_lantern` equivalent):
7520
- // when `signature.simpleSystemPrompt` is true AND the model is eligible
7521
- // (Opus 4.7, -eap variants), skip the anti-verbosity boilerplate. Real CC's
7522
- // simple-prompt mode strips this kind of post-hoc behavioral hand-holding on
7523
- // models that don't need it. Conservative implementation: gates ONLY the
7524
- // anti-verbosity push, not the identity / billing / sanitized blocks. Saves
7525
- // ~600-1500 tokens per request on eligible models without touching the CC
7526
- // fingerprint markers the server checks.
7527
- const skipAntiVerbosity = signature.simpleSystemPrompt === true && isSimpleSystemPromptEligible(signature.modelId);
7528
-
7529
- if (
7530
- !titleGeneratorRequest &&
7531
- !skipAntiVerbosity &&
7532
- signature.modelId &&
7533
- (isOpus46Model(signature.modelId) || isOpus47Model(signature.modelId))
7534
- ) {
7535
- const avConfig = signature.antiVerbosity;
7536
- if (avConfig?.enabled !== false) {
7537
- sanitized.push({ type: "text", text: ANTI_VERBOSITY_SYSTEM_PROMPT });
7538
- }
7539
- if (avConfig?.length_anchors !== false) {
7540
- sanitized.push({ type: "text", text: NUMERIC_LENGTH_ANCHORS_PROMPT });
7541
- }
7542
- }
7543
-
7544
- if (!signature.enabled) {
7545
- return sanitized;
7546
- }
7547
-
7548
- // Lean system prompt for non-main requests (title-gen is already handled
7549
- // above via the COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT swap). For "title"
7550
- // and "small" request roles — one-off queries that don't belong to the main
7551
- // REPL thread — we skip billing identity + CC identity injection. This
7552
- // matches the spirit of real CC's querySource gates: identity context is
7553
- // for interactive main-thread conversations, not fire-and-forget calls.
7554
- // Opt-in (default off) because it changes the system-prompt shape.
7555
- const leanNonMain =
7556
- signature.leanNonMain === true &&
7557
- (signature.requestRole === "title" || signature.requestRole === "small") &&
7558
- !titleGeneratorRequest;
7559
- if (leanNonMain) {
7560
- return sanitized;
7561
- }
7562
-
7563
- // Build attribution header
7564
- const billingHeader = buildAnthropicBillingHeader(
7565
- signature.claudeCliVersion,
7566
- signature.firstUserMessage,
7567
- signature.provider,
7568
- signature.workload,
7569
- );
7570
-
7571
- // Select the identity string (matches real CC getCLISyspromptPrefix())
7572
- const identityString = getCLISyspromptPrefix();
7573
-
7574
- // Determine cache policy
7575
- const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
7576
-
7577
- // Determine if we should use boundary mode (Path B)
7578
- // Real CC: shouldUseGlobalCacheScope() is a GrowthBook feature flag.
7579
- // We simulate it via config: boundary_marker=true or CLAUDE_CODE_FORCE_GLOBAL_CACHE=1.
7580
- const useBoundaryMode =
7581
- effectiveCachePolicy.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
7582
-
7583
- // Run the real CC splitSysPromptPrefix algorithm to get blocks with cacheScope
7584
- const scopedBlocks = splitSysPromptPrefix(sanitized, billingHeader || undefined, identityString, useBoundaryMode);
7585
-
7586
- // Convert scoped blocks to wire format using getCacheControlForScope
7587
- // (mirrors real CC buildSystemPromptBlocks → map + getCacheControl)
7588
- return scopedBlocks.map((block) => {
7589
- const cc = getCacheControlForScope(block.cacheScope, effectiveCachePolicy);
7590
- return {
7591
- type: "text",
7592
- text: block.text,
7593
- ...(cc !== null && { cache_control: cc }),
7594
- };
7595
- });
7596
- }
7597
-
7598
- /**
7599
- * @param {string} incomingBeta
7600
- * @param {boolean} signatureEnabled
7601
- * @param {string} model
7602
- * @param {"anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle"} provider
7603
- * @param {string[]} [customBetas]
7604
- * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
7605
- * @param {string} [requestPath]
7606
- * @param {boolean} [hasFileReferences]
7607
- * @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
7608
- * @param {boolean} [fastModeActive] - When true, emits FAST_MODE_BETA_FLAG (fast-mode-2026-02-01).
7609
- * Must be derived structurally from the already-transformed outgoing body (body.includes('"speed":"fast"')).
7610
- * Only passed from the buildRequestHeaders call site (after body transform). The pre-transform
7611
- * computedBetaHeader call site (index.mjs:2905) leaves this undefined so the latch never
7612
- * accumulates fast-mode independently of actual body content.
7613
- * @returns {string}
7614
- */
7615
- // Mirrors CC's Kw(model) effort eligibility: returns false for claude-3-* and the
7616
- // explicit older 4.x exclusion set (opus-4-0/4-1, sonnet-4-0/4-5, haiku-4-5), true
7617
- // for every other model (effort-capable: Opus 4.5/4.6/4.7/4.8, Sonnet 4.6, etc.).
7618
- const _EFFORT_EXCLUDED_MODELS = [
7619
- /claude-opus-4-0/i,
7620
- /claude-opus-4-1/i,
7621
- /claude-sonnet-4-0/i,
7622
- /claude-sonnet-4-5/i,
7623
- /claude-haiku-4-5/i,
7624
- ];
7625
- function isEffortCapableModel(model) {
7626
- if (!model) return false;
7627
- if (CLAUDE_3_MODEL_RE.test(model)) return false;
7628
- return !_EFFORT_EXCLUDED_MODELS.some((re) => re.test(model));
7629
- }
7630
-
7631
- function buildAnthropicBetaHeader(
7632
- incomingBeta,
7633
- signatureEnabled,
7634
- model,
7635
- provider,
7636
- customBetas,
7637
- strategy,
7638
- requestPath,
7639
- hasFileReferences,
7640
- adaptiveOverride,
7641
- tokenEconomy,
7642
- microcompactBetas, // NEW 11th param
7643
- fastModeActive, // NEW 12th param — see @param above
7644
- ) {
7645
- const incomingBetasList = incomingBeta
7646
- .split(",")
7647
- .map((b) => b.trim())
7648
- .filter(Boolean);
7649
-
7650
- const betas = ["oauth-2025-04-20"];
7651
- const disableExperimentalBetas = isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS);
7652
- const isMessagesCountTokensPath = requestPath === "/v1/messages/count_tokens";
7653
- const isFilesEndpoint = requestPath?.startsWith("/v1/files") ?? false;
7654
-
7655
- if (!signatureEnabled) {
7656
- betas.push("interleaved-thinking-2025-05-14");
7657
- if (isMessagesCountTokensPath) {
7658
- betas.push(TOKEN_COUNTING_BETA_FLAG);
7659
- }
7660
- let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
7661
- if (disableExperimentalBetas) {
7662
- mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
7663
- }
7664
- return mergedBetas.join(",");
7665
- }
7666
-
7667
- const nonInteractive = isNonInteractiveMode();
7668
- const haiku = isHaikuModel(model);
7669
- const isRoundRobin = strategy === "round-robin";
7670
- const te = tokenEconomy || {};
7671
-
7672
- // === ALWAYS-ON BETAS (Claude Code v2.1.90 base set) ===
7673
- // These are ALWAYS included regardless of env vars or feature flags.
7674
- // NOTE: Real Claude Code skips this beta for Haiku, but we include it
7675
- // so that Haiku subagents (via model-router delegation) get full mimic
7676
- // behavior from the Anthropic API.
7677
- betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
7678
-
7679
- // v2.1.150: advanced-tool-use / tool-search-tool removed from always-on.
7680
- // CC 2.1.150 D5q does not push these per-request. Available via custom betas.
7681
-
7682
- // v2.1.150: fast-mode-2026-02-01 removed from always-on (CC sends only when speed feature active).
7683
-
7684
- // v2.1.195: effort-2025-11-24 is no longer always-on; it is now a model-gated
7685
- // default emitted in the conditional section below for effort-capable models
7686
- // (mirrors CC's Kw(model)).
7687
-
7688
- // Interleaved thinking — real CC's i01 pushes via hv4(model), which is
7689
- // (firstParty && non-Claude-3). Claude 3.x models don't support interleaved
7690
- // thinking and real CC never sends this flag for them, so emitting it
7691
- // diverges the fingerprint for legacy Haiku/Sonnet 3.x requests.
7692
- if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !CLAUDE_3_MODEL_RE.test(model)) {
7693
- betas.push("interleaved-thinking-2025-05-14");
7694
- }
7695
-
7696
- // Context 1M — when adaptive override is provided, use it; otherwise fall back to static check.
7697
- {
7698
- const use1M =
7699
- adaptiveOverride && typeof adaptiveOverride.use1MContext === "boolean"
7700
- ? adaptiveOverride.use1MContext
7701
- : hasOneMillionContext(model);
7702
- if (use1M) {
7703
- betas.push("context-1m-2025-08-07");
7704
- }
7705
- }
7706
-
7707
- // Prompt caching scope — always-on EXCEPT in round-robin (per-workspace state conflicts)
7708
- if (!isRoundRobin) {
7709
- betas.push("prompt-caching-scope-2026-01-05");
7710
- }
7711
- // v2.1.150: extended-cache-ttl for better cache hit rates (opt-out via token_economy.extended_cache_ttl = false).
7712
- if (te.extended_cache_ttl !== false && !isRoundRobin) {
7713
- betas.push("extended-cache-ttl-2025-04-11");
7714
- }
7715
-
7716
- // === CONDITIONAL BETAS (model/context-dependent) ===
7717
-
7718
- // First-party provider predicate (OAuth path). Mirrors CC's ZO(provider) which
7719
- // excludes bedrock/vertex/mantle. context-management and effort are first-party
7720
- // only here, so they must never be pushed on bedrock/vertex.
7721
- const isFirstPartyProvider = provider !== "vertex" && provider !== "bedrock" && provider !== "mantle";
7722
-
7723
- // v2.1.195: context-management-2025-06-27 is default-ON for first-party non-claude-3
7724
- // models (incl. Haiku 4.5), mirroring CC's n0d(model) eligibility path. Earlier
7725
- // analyses read only the separate USE_API_CONTEXT_MANAGEMENT env term (hardcoded
7726
- // && false) and recorded it as off; 2.1.195 confirms it ships by default for modern
7727
- // first-party models. Opt out via token_economy.context_management = false.
7728
- if (te.context_management !== false && isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model)) {
7729
- betas.push("context-management-2025-06-27");
7730
- }
7731
-
7732
- // v2.1.195: effort-2025-11-24 is a model-gated default for effort-capable models
7733
- // (Opus 4.5/4.6/4.7/4.8, Sonnet 4.6), mirroring CC's Kw(model). Excluded for
7734
- // claude-3-*, opus-4-0, opus-4-1, sonnet-4-0, sonnet-4-5, haiku-4-5. First-party
7735
- // only; opt out via token_economy.effort = false.
7736
- if (te.effort !== false && isFirstPartyProvider && isEffortCapableModel(model)) {
7737
- betas.push(_EFFORT_BETA_FLAG);
7738
- }
7739
-
7740
- // v2.1.150: structured-outputs gated behind opt-in (CC has behind tengu_tool_pear flag).
7741
- if (te.structured_outputs && supportsStructuredOutputs(model)) {
7742
- betas.push("structured-outputs-2025-12-15");
7743
- }
7744
-
7745
- // Web search — for models that support it
7746
- if (supportsWebSearch(model)) {
7747
- betas.push("web-search-2025-03-05");
7748
- }
7749
-
7750
- // Advisor tool — in CC this is gated by server-side feature flag
7751
- // (tengu_sage_compass2) and firstParty+isLoggedIn. Since we can't check
7752
- // CC's feature flags, include it unconditionally for Claude 4+ models.
7753
- // CC v108 sends it in MITM captures for Max/Pro users.
7754
- if (!CLAUDE_3_MODEL_RE.test(model)) {
7755
- betas.push("advisor-tool-2026-03-01");
7756
- }
7757
-
7758
- // context-hint-2026-04-09 — introduced in CC v2.1.110. Paired with the body
7759
- // field `context_hint: { enabled: true }` (injected in transformRequestBody).
7760
- // Real CC gates it on includeFirstPartyBetas + querySource startsWith
7761
- // "repl_main_thread" (i.e. interactive sessions on 1P Anthropic only).
7762
- // Server responds with hints suggesting which messages to forget for
7763
- // token-efficient context compaction. Sticky: disabled permanently on
7764
- // 400/409/529 errors referencing the hint. Users can opt out via
7765
- // token_economy.context_hint = false.
7766
- // Mimicry note: real CC gates context-hint on querySource.startsWith("repl_main_thread").
7767
- // We infer main-thread from body shape via classifyRequestRole and pass it via
7768
- // tokenEconomy.__requestRole. Treat absent marker as "main" for backward compat.
7769
- const _isMainThread = te.__requestRole == null || te.__requestRole === "main";
7770
- if (isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model) && te.context_hint !== false && _isMainThread) {
7771
- betas.push("context-hint-2026-04-09");
7772
- }
7773
-
7774
- // Files API — scoped to file endpoints/references
7775
- if (isFilesEndpoint || hasFileReferences) {
7776
- betas.push("files-api-2025-04-14");
7777
- }
7778
-
7779
- // Token counting endpoint
7780
- if (isMessagesCountTokensPath) {
7781
- betas.push(TOKEN_COUNTING_BETA_FLAG);
7782
- }
7783
-
7784
- // === TOKEN ECONOMY BETAS (on by default for token savings) ===
7785
-
7786
- // v2.1.150: redact-thinking is default-ON in CC (first-party, non-SDK, thinking models).
7787
- // Opt out via `/anthropic set redact-thinking off` to see thinking content.
7788
- if (te.redact_thinking !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
7789
- betas.push("redact-thinking-2026-02-12");
7790
- }
7791
-
7792
- // v2.1.150: thinking-token-count for token budget tracking (behind tengu_chert_bezel in CC).
7793
- // Default ON for token economy visibility. Opt out via token_economy.thinking_token_count = false.
7794
- if (te.thinking_token_count !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
7795
- betas.push("thinking-token-count-2026-05-13");
7796
- }
7797
-
7798
- // compact-2026-01-12 and mcp-client-2025-11-20 exist only in docs, not runtime.
7799
-
7800
- // afk-mode — NOT auto-included (requires user opt-in)
7801
- // Available via: /anthropic betas add afk-mode-2026-01-31
7802
-
7803
- // === MICROCOMPACT BETAS (context-aware, Phase 3 Task 3.4) ===
7804
- if (microcompactBetas?.length) {
7805
- for (const mb of microcompactBetas) {
7806
- if (!betas.includes(mb)) betas.push(mb);
7807
- }
7808
- }
7809
-
7810
- // Fast-mode beta — the PLUGIN injects speed:"fast" into the body, not the host.
7811
- // Because the host never sends speed:"fast", it also never sends the matching beta.
7812
- // We must add it here, derived structurally from the already-transformed body so
7813
- // it is always in lockstep with the speed field. Added BEFORE the dedupe merge so
7814
- // any duplicate (e.g. via incoming passthrough) is collapsed without duplication.
7815
- // Unlike effort-2025-11-24 / advanced-tool-use / tool-search-tool (which arrive via
7816
- // incoming-header passthrough because the HOST sets those body features), fast-mode
7817
- // is plugin-only and requires this explicit push.
7818
- if (fastModeActive) betas.push(FAST_MODE_BETA_FLAG);
7819
-
7820
- // Merge incoming betas from the original request, filtering out host-injected
7821
- // betas (e.g. fine-grained-tool-streaming-2025-05-14, structured-outputs-2025-11-13)
7822
- // that OpenCode's Anthropic SDK adds but real Claude Code never sends.
7823
- const filteredIncoming = incomingBetasList.filter((b) => !HOST_SDK_BETAS_BLOCKLIST.has(b));
7824
- let mergedBetas = [...new Set([...betas, ...filteredIncoming])];
7825
-
7826
- // Add custom betas from config
7827
- if (customBetas?.length) {
7828
- for (const custom of customBetas) {
7829
- const resolved = BETA_SHORTCUTS.get(custom) || custom;
7830
- if (resolved && !mergedBetas.includes(resolved)) {
7831
- mergedBetas.push(resolved);
7832
- }
7833
- }
7834
- }
7835
-
7836
- // Filter out experimental betas only if explicitly disabled.
7837
- // WARNING: The EXPERIMENTAL_BETA_FLAGS set overlaps with most always-on betas.
7838
- // Enabling CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS effectively strips Claude Code
7839
- // mimicry betas, leaving only oauth-2025-04-20, claude-code-20250219, and effort-*.
7840
- // Use this escape hatch only for debugging or when betas cause API rejections.
7841
- if (disableExperimentalBetas) {
7842
- mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
7843
- }
7844
-
7845
- // Remove betas unsupported by Bedrock
7846
- if (provider === "bedrock") {
7847
- mergedBetas = mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta));
7848
- }
7849
-
7850
- return mergedBetas.join(",");
7851
- }
7852
-
7853
- /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
7854
-
7855
- /**
7856
- * Map budgetTokens to an effort level.
7857
- * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
7858
- * @param {number} budgetTokens
7859
- * @returns {ThinkingEffort}
7860
- */
7861
- function budgetTokensToEffort(budgetTokens) {
7862
- if (budgetTokens <= 1024) return "low";
7863
- if (budgetTokens <= 8000) return "medium";
7864
- return "high";
7865
- }
7866
-
7867
- // QA fix L3: budgetTokensToEffort() removed — dead code, never called
7868
- // QA fix L4: isValidEffort() removed — dead code, never called
7869
-
7870
- /**
7871
- * Normalise the `thinking` block in the request body for the target model:
7872
- * - Opus 4.6 / 4.7 / 4.8 / Sonnet 4.6 (adaptive thinking): produces
7873
- * `{ type: "adaptive" }`. This is REQUIRED for Opus 4.7/4.8 — manual
7874
- * `{ type: "enabled", budget_tokens }` returns a 400 on those models.
7875
- * - Older models: passes the existing thinking block through unchanged.
7876
- *
7877
- * @param {any} thinking
5847
+ * @param {string} incomingBeta
5848
+ * @param {boolean} signatureEnabled
7878
5849
  * @param {string} model
7879
- * @returns {any}
7880
- */
7881
- function normalizeThinkingBlock(thinking, model) {
7882
- // If thinking is absent or not an object, pass through
7883
- if (!thinking || typeof thinking !== "object") {
7884
- return thinking;
7885
- }
7886
-
7887
- // Adaptive thinking models always get { type: "adaptive" }
7888
- // regardless of what format the incoming thinking block has
7889
- if (isAdaptiveThinkingModel(model)) {
7890
- // Check for env-var override to force budget_tokens fallback
7891
- if (isTruthyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
7892
- // Fallback: return as-is if already budget_tokens shape, otherwise default
7893
- if (thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
7894
- return thinking;
7895
- }
7896
- const parsedBudget = parseInt(process.env.MAX_THINKING_TOKENS, 10);
7897
- return { type: "enabled", budget_tokens: Number.isNaN(parsedBudget) ? 16000 : parsedBudget };
7898
- }
7899
- return { type: "adaptive" };
7900
- }
7901
-
7902
- // Non-adaptive models: pass through unchanged
7903
- return thinking;
7904
- }
7905
-
7906
- /**
7907
- * Map Node.js platform to Stainless OS header value.
7908
- * @param {NodeJS.Platform} value
7909
- * @returns {string}
7910
- */
7911
- function getStainlessOs(value) {
7912
- if (value === "darwin") return "macOS";
7913
- if (value === "win32") return "Windows";
7914
- if (value === "linux") return "Linux";
7915
- return value;
7916
- }
7917
-
7918
- /**
7919
- * Normalize Node.js arch to Stainless arch header value.
7920
- * @param {string} value
7921
- * @returns {string}
7922
- */
7923
- function getStainlessArch(value) {
7924
- if (value === "x64") return "x64";
7925
- if (value === "arm64") return "arm64";
7926
- return value;
7927
- }
7928
-
7929
- /**
7930
- * Resolve latest claude-code package version from npm registry.
7931
- * Returns null on timeout/network/parse failures.
7932
- * @param {number} timeoutMs
7933
- * @returns {Promise<string | null>}
7934
- */
7935
- async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
7936
- const controller = new AbortController();
7937
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
7938
- try {
7939
- const response = await fetch(CLAUDE_CODE_NPM_LATEST_URL, {
7940
- method: "GET",
7941
- headers: { accept: "application/json" },
7942
- signal: controller.signal,
7943
- });
7944
- if (!response.ok) return null;
7945
- const data = await response.json();
7946
- if (!data || typeof data !== "object") return null;
7947
- return typeof data.version === "string" && data.version ? data.version : null;
7948
- } catch {
7949
- return null;
7950
- } finally {
7951
- clearTimeout(timeout);
7952
- }
7953
- }
7954
-
7955
- /**
7956
- * Build request headers from input and init, applying OAuth requirements.
7957
- * Preserves behaviors D1-D7.
7958
- *
7959
- * @param {any} input
7960
- * @param {Record<string, any>} requestInit
7961
- * @param {string} accessToken
7962
- * @param {string | undefined} requestBody
7963
- * @param {URL | null} requestUrl
7964
- * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[], sessionId?: string}} signature
7965
- * @returns {Headers}
7966
- */
7967
- function buildRequestHeaders(
7968
- input,
7969
- requestInit,
7970
- accessToken,
7971
- requestBody,
7972
- requestUrl,
7973
- signature,
7974
- adaptiveOverride,
7975
- tokenEconomy,
7976
- ) {
7977
- const requestHeaders = new Headers();
7978
- if (input instanceof Request) {
7979
- input.headers.forEach((value, key) => {
7980
- requestHeaders.set(key, value);
7981
- });
7982
- }
7983
- if (requestInit.headers) {
7984
- if (requestInit.headers instanceof Headers) {
7985
- requestInit.headers.forEach((value, key) => {
7986
- requestHeaders.set(key, value);
7987
- });
7988
- } else if (Array.isArray(requestInit.headers)) {
7989
- for (const [key, value] of requestInit.headers) {
7990
- if (typeof value !== "undefined") {
7991
- requestHeaders.set(key, String(value));
7992
- }
7993
- }
7994
- } else {
7995
- for (const [key, value] of Object.entries(requestInit.headers)) {
7996
- if (typeof value !== "undefined") {
7997
- requestHeaders.set(key, String(value));
7998
- }
7999
- }
8000
- }
8001
- }
8002
-
8003
- // Preserve all incoming beta headers while ensuring OAuth requirements
8004
- const incomingBeta = requestHeaders.get("anthropic-beta") || "";
8005
- const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
8006
- const provider = detectProvider(requestUrl);
8007
- // Detect fast mode structurally from the already-transformed body string.
8008
- // transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
8009
- // so speed:"fast" is already present when fast mode fired. Same detection idiom as the
8010
- // toast check (~line 3018). This is the correct call site for fast-mode: the
8011
- // computedBetaHeader call at index.mjs:2905 runs BEFORE body transform, so fastModeActive
8012
- // is always false/absent there, keeping the latch free of fast-mode. The
8013
- // requestHeaders.set("anthropic-beta", mergedBetas) call below is what reaches the wire.
8014
- const fastModeActive = typeof requestBody === "string" && requestBody.includes('"speed":"fast"');
8015
- const mergedBetas = buildAnthropicBetaHeader(
8016
- incomingBeta,
8017
- signature.enabled,
8018
- model,
8019
- provider,
8020
- signature.customBetas,
8021
- signature.strategy,
8022
- requestUrl?.pathname,
8023
- hasFileReferences,
8024
- adaptiveOverride,
8025
- tokenEconomy,
8026
- undefined, // microcompactBetas: not available at this call site (handled via computedBetaHeader path at 2905)
8027
- fastModeActive, // NEW 12th param: emit fast-mode-2026-02-01 beta when body contains speed:"fast"
8028
- );
8029
-
8030
- const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
8031
- const bearerToken = authTokenOverride || accessToken;
8032
-
8033
- requestHeaders.set("authorization", `Bearer ${bearerToken}`);
8034
- requestHeaders.set("anthropic-beta", mergedBetas);
8035
- requestHeaders.set("user-agent", buildExtendedUserAgent(signature.claudeCliVersion));
8036
- if (signature.enabled) {
8037
- requestHeaders.set("anthropic-version", "2023-06-01");
8038
- // Fix #6: x-app is "cli" for interactive mode, "cli-bg" for background tasks.
8039
- // Real CC (client.ts:106): 'x-app': 'cli' (foreground) or 'cli-bg' (background agent).
8040
- requestHeaders.set("x-app", isTruthyEnv(process.env.CLAUDE_CODE_BACKGROUND) ? "cli-bg" : "cli");
8041
- // Fix #3: X-Claude-Code-Session-Id — sent in ALL requests by real CC (client.ts:108).
8042
- // Value matches metadata.user_id.session_id for server-side correlation.
8043
- if (signature.sessionId) {
8044
- requestHeaders.set("X-Claude-Code-Session-Id", signature.sessionId);
8045
- }
8046
- requestHeaders.set("x-stainless-arch", getStainlessArch(process.arch));
8047
- requestHeaders.set("x-stainless-lang", "js");
8048
- requestHeaders.set("x-stainless-os", getStainlessOs(process.platform));
8049
- // Real CC sends 0.81.0 (confirmed via proxy capture), not the internal 0.208.0.
8050
- // WATCH: most-likely-to-drift mimesis constant. Stable v2.1.97 → v2.1.105 (only
8051
- // the minifier identifier renamed, d66 → g86). Re-verify on every upstream bump:
8052
- // rg -n '"0\.\d+\.\d+"' _tmp_claude_pkg/<version>/package/cli.js | rg -C2 stainless
8053
- // See docs/future-improvements.md §7 and claude-code-reverse-engineering.md §16.
8054
- requestHeaders.set("x-stainless-package-version", "0.94.0");
8055
- // Real CC on Windows/Node reports "node" — confirmed via proxy capture.
8056
- requestHeaders.set("x-stainless-runtime", "node");
8057
- requestHeaders.set("x-stainless-runtime-version", process.version);
8058
- const incomingRetryCount = requestHeaders.get("x-stainless-retry-count");
8059
- requestHeaders.set(
8060
- "x-stainless-retry-count",
8061
- incomingRetryCount && !isFalsyEnv(incomingRetryCount) ? incomingRetryCount : "0",
8062
- );
8063
- // x-stainless-timeout: real CC sends 600 on ALL requests (confirmed via proxy capture).
8064
- requestHeaders.set("x-stainless-timeout", "600");
8065
- // anthropic-dangerous-direct-browser-access: real CC sends this on all requests.
8066
- requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
8067
- const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
8068
- if (stainlessHelpers) {
8069
- requestHeaders.set("x-stainless-helper", stainlessHelpers);
8070
- }
8071
-
8072
- for (const [key, value] of Object.entries(parseAnthropicCustomHeaders())) {
8073
- requestHeaders.set(key, value);
8074
- }
8075
- if (process.env.CLAUDE_CODE_CONTAINER_ID) {
8076
- requestHeaders.set("x-claude-remote-container-id", process.env.CLAUDE_CODE_CONTAINER_ID);
8077
- }
8078
- if (process.env.CLAUDE_CODE_REMOTE_SESSION_ID) {
8079
- requestHeaders.set("x-claude-remote-session-id", process.env.CLAUDE_CODE_REMOTE_SESSION_ID);
8080
- }
8081
- if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) {
8082
- requestHeaders.set("x-client-app", process.env.CLAUDE_AGENT_SDK_CLIENT_APP);
8083
- }
8084
- if (isTruthyEnv(process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) {
8085
- requestHeaders.set("x-anthropic-additional-protection", "true");
8086
- }
8087
-
8088
- // x-client-request-id: real CC 2.1.195's first-party fetch middleware (Ukd) sets
8089
- // this to crypto.randomUUID() on every first-party request. Re-emit a random uuid.
8090
- requestHeaders.set("x-client-request-id", randomUUID());
8091
- }
8092
- requestHeaders.delete("x-api-key");
8093
- // x-session-affinity: set by opencode SDK but NOT in real CC. Strip it.
8094
- requestHeaders.delete("x-session-affinity");
8095
-
8096
- return requestHeaders;
8097
- }
8098
-
8099
- /**
8100
- * Resolve max_tokens for a request based on output cap configuration.
8101
- * If the caller specified max_tokens, it is preserved. Otherwise, defaults
8102
- * to 8K and escalates to 64K after an output truncation (stop_reason: "max_tokens").
8103
- *
8104
- * @param {Record<string, any>} body - Parsed request body
8105
- * @param {import('./lib/config.mjs').AnthropicAuthConfig} config
8106
- * @returns {number | undefined} Resolved max_tokens value, or undefined for passthrough
8107
- */
8108
- function resolveMaxTokens(body, config) {
8109
- if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
8110
- if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
8111
- // QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
8112
- // completes. The timing works correctly for "escalate for one turn" because this function runs
8113
- // BEFORE the next request's stream starts. If the response pipeline changes to update stop
8114
- // reason mid-stream or before response completion, this ordering assumption would break.
8115
- const escalated = sessionMetrics.lastStopReason === "max_tokens";
8116
- const result = escalated
8117
- ? (config.output_cap.escalated_max_tokens ?? 64_000)
8118
- : (config.output_cap.default_max_tokens ?? 8_000);
8119
- // Reset after escalation is consumed (sticky for exactly one turn)
8120
- if (escalated) {
8121
- sessionMetrics.lastStopReason = null;
8122
- }
8123
- return result;
8124
- }
8125
-
8126
- /**
8127
- * Transform the request body: system prompt sanitization and tool prefixing.
8128
- * Preserves behaviors E1-E7.
8129
- *
8130
- * @param {string | undefined} body
8131
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
8132
- * @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
8133
- * @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
8134
- * @param {import('./lib/config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
8135
- * @returns {string | undefined}
8136
- */
8137
- function transformRequestBody(body, signature, runtime, betaHeader, config) {
8138
- if (!body || typeof body !== "string") return body;
8139
-
8140
- const TOOL_PREFIX = "mcp_";
8141
-
8142
- try {
8143
- const parsed = JSON.parse(body);
8144
- // Diagnostic: log the incoming model + thinking/speed shape so model-routing
8145
- // issues (e.g. an unrecognized fast variant, or "No available account"
8146
- // symptoms tied to a specific model) are observable with /anthropic set debug on.
8147
- // NOTE: transformRequestBody is a top-level function outside the plugin
8148
- // closure where debugLog() lives, so we mirror debugLog's behaviour directly
8149
- // (console.error + same prefix, gated on config.debug).
8150
- if (config?.debug) {
8151
- console.error("[opencode-anthropic-auth]", "transformRequestBody: incoming model", {
8152
- model: parsed.model,
8153
- adaptive: isAdaptiveThinkingModel(parsed.model || ""),
8154
- thinkingType: parsed.thinking?.type,
8155
- hasEffort: parsed.effort !== undefined || parsed.output_config?.effort !== undefined,
8156
- speed: parsed.speed,
8157
- });
8158
- }
8159
- // Output cap: resolve max_tokens before any other body transforms
8160
- if (config?.output_cap?.enabled) {
8161
- parsed.max_tokens = resolveMaxTokens(parsed, config);
8162
- }
8163
- // Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
8164
- // doesn't forward custom HTTP headers. First-party API rejects "betas" in body
8165
- // with "Extra inputs are not permitted" — betas are header-only for first-party.
8166
- if (signature.enabled && betaHeader && signature.provider === "bedrock") {
8167
- const betaArray = betaHeader
8168
- .split(",")
8169
- .map((b) => b.trim())
8170
- .filter(Boolean)
8171
- .filter((b) => b !== "oauth-2025-04-20");
8172
- parsed.anthropic_beta = betaArray;
8173
- }
8174
- // Strip any incoming "betas" field — API rejects it as unknown
8175
- if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
8176
- delete parsed.betas;
8177
- }
8178
- // Normalize thinking block for adaptive (Opus 4.6 / Sonnet 4.6) vs manual (older models).
8179
- // Real CC always sends thinking:{type:"adaptive"} for adaptive models even if the
8180
- // upstream SDK didn't include it. Inject it when missing to match the fingerprint.
8181
- if (Object.prototype.hasOwnProperty.call(parsed, "thinking")) {
8182
- parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
8183
- } else if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
8184
- parsed.thinking = { type: "adaptive" };
8185
- }
8186
-
8187
- // Fingerprint fix: real Claude Code v2.1.87+ nests the effort control inside
8188
- // `output_config.effort` (via Lyz() in cli.js). opencode's provider transform
8189
- // for variant=max on Opus 4.6 / Sonnet 4.6 sets `effort` at the top level,
8190
- // which causes Anthropic's server to fingerprint the body as non-CC and bill
8191
- // it as pay-as-you-go — surfacing as "You're out of extra usage" even on a
8192
- // valid Max plan. Move it into output_config when we're talking to an
8193
- // adaptive-thinking model so the wire shape matches real CC.
8194
- if (typeof parsed.effort === "string" && parsed.model && isAdaptiveThinkingModel(parsed.model)) {
8195
- if (!parsed.output_config || typeof parsed.output_config !== "object") {
8196
- parsed.output_config = {};
8197
- }
8198
- if (!("effort" in parsed.output_config)) {
8199
- parsed.output_config.effort = parsed.effort;
8200
- }
8201
- delete parsed.effort;
8202
- } else if (Object.prototype.hasOwnProperty.call(parsed, "effort")) {
8203
- // Non-adaptive models never carry a top-level effort in real CC — strip it
8204
- // to avoid polluting the fingerprint for models like Haiku.
8205
- delete parsed.effort;
8206
- }
8207
-
8208
- // Claude Code v2.1.117+: default effort for Pro/Max on adaptive models is
8209
- // "high" (was "medium"). If the host omits effort entirely, inject the
8210
- // default so the wire shape matches real CC.
8211
- if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
8212
- if (!parsed.output_config || typeof parsed.output_config !== "object") {
8213
- parsed.output_config = {};
8214
- }
8215
- if (!("effort" in parsed.output_config)) {
8216
- parsed.output_config.effort = "high";
8217
- }
8218
- }
8219
-
8220
- // Claude Code temperature rule: when extended thinking is active (any type),
8221
- // temperature must be omitted (undefined). Otherwise default to 1.
8222
- const thinkingActive =
8223
- parsed.thinking &&
8224
- typeof parsed.thinking === "object" &&
8225
- (parsed.thinking.type === "adaptive" || parsed.thinking.type === "enabled");
8226
- if (thinkingActive) {
8227
- // Anthropic API rejects temperature when thinking is enabled
8228
- delete parsed.temperature;
8229
-
8230
- // Claude Code v2.1.84: inject the context_management body field ONLY when the
8231
- // user has explicitly opted in via token_economy.context_management. Note: as of
8232
- // v2.1.195 the context-management *beta header* is default-ON for first-party
8233
- // non-claude-3 models (see buildAnthropicBetaHeader), but the body field stays
8234
- // opt-in. A top-level context_management field WITHOUT the beta is rejected with
8235
- // "context_management: Extra inputs are not permitted"; the beta being present
8236
- // without the field is fine. Gating the field on explicit opt-in keeps the field
8237
- // ⊆ beta invariant, so the 400 never fires.
8238
- if (
8239
- config?.token_economy?.context_management &&
8240
- !CLAUDE_3_MODEL_RE.test(parsed.model || "") &&
8241
- !parsed.context_management
8242
- ) {
8243
- parsed.context_management = {
8244
- edits: [{ type: "clear_thinking_20251015", keep: "all" }],
8245
- };
8246
- }
8247
- } else {
8248
- // Claude Code always uses temperature: 1 for non-thinking requests (RE doc §5.2, never 0)
8249
- parsed.temperature = 1;
8250
- }
8251
-
8252
- // Strip leaked /anthropic slash command messages from conversation history.
8253
- // OpenCode may include command text and sendCommandMessage output as regular
8254
- // user messages even when output.noReply = true was set. Filter them out
8255
- // so the agent never sees /anthropic commands in its context.
8256
- if (Array.isArray(parsed.messages)) {
8257
- parsed.messages = stripSlashCommandMessages(parsed.messages);
8258
- }
8259
-
8260
- // === Token economy: layered message/history compaction ===
8261
- // Only applies to main-thread requests (subagents/title-gen stay untouched).
8262
- // Strategies stack in order: TTL thinking strip → proactive microcompact →
8263
- // trailing-summary trim → tool_result dedupe. Each is independently gated.
8264
- const te = config?.token_economy || {};
8265
- const tes = runtime?.tokenEconomySession;
8266
- const isMainRole = runtime?.requestRole === "main" || runtime?.requestRole == null;
8267
-
8268
- // `conservative` (default ON) disables all history-rewriting and tool-array
8269
- // transforms. These optimizations shrink each request body but cause the
8270
- // prompt-cache prefix to change turn-to-turn, invalidating the 1h cache
8271
- // and forcing a fresh cache_write each turn — which costs 2x base input
8272
- // tokens. For long opencode sessions, cache reuse dominates; flip to
8273
- // `false` only if you have measurements showing otherwise. Adaptive
8274
- // thinking zero-out remains active (affects only the thinking budget,
8275
- // not cached content).
8276
- const conservative = te.conservative !== false;
8277
-
8278
- if (!conservative && isMainRole && Array.isArray(parsed.messages) && tes) {
8279
- // (1) TTL-based thinking strip
8280
- if (te.ttl_thinking_strip !== false) {
8281
- const ttlMs = signature?.cachePolicy?.ttl === "5m" ? 5 * 60_000 : 60 * 60_000;
8282
- const res = applyTtlThinkingStrip(parsed.messages, {
8283
- lastClearMs: tes.lastThinkingStripMs,
8284
- ttlMs,
8285
- });
8286
- if (res.changed) {
8287
- parsed.messages = res.messages;
8288
- tes.lastThinkingStripMs = res.ranStripAt;
8289
- tes.thinkingStripped += res.cleared;
8290
- }
8291
- }
8292
-
8293
- // (1b) Session-wide reproducible-tool result dedupe (Phase C C3, opt-in)
8294
- // Pure over message history; runs before microcompact so dedup'd stubs
8295
- // are visible to downstream size estimation. Gated by
8296
- // token_economy_strategies.tool_result_dedupe_session_wide (default off).
8297
- if (config?.token_economy_strategies?.tool_result_dedupe_session_wide === true) {
8298
- const res = applySessionToolResultDedupe(parsed.messages);
8299
- if (res.changed) parsed.messages = res.messages;
8300
- }
8301
-
8302
- // (2) Proactive microcompact (client-side, pre-422)
8303
- if (te.proactive_microcompact !== false) {
8304
- const estimated = estimatePromptTokensFromParsed(parsed);
8305
- const cw = 200_000; // conservative — 1M models still benefit
8306
- const res = applyProactiveMicrocompact(parsed.messages, {
8307
- estimatedTokens: estimated,
8308
- contextWindow: cw,
8309
- percent: te.microcompact_percent ?? 70,
8310
- keepRecent: te.microcompact_keep_recent ?? 8,
8311
- });
8312
- if (res.changed) {
8313
- parsed.messages = res.messages;
8314
- tes.lastMicrocompactMs = Date.now();
8315
- tes.toolResultsCompacted += res.cleared;
8316
- }
8317
- }
8318
-
8319
- // (3) Trailing-summary trim (opt-in)
8320
- if (te.trailing_summary_trim === true) {
8321
- const res = applyTrailingSummaryTrim(parsed.messages);
8322
- if (res.changed) parsed.messages = res.messages;
8323
- }
8324
-
8325
- // (4) Cross-turn tool_result dedupe (opt-in)
8326
- if (te.tool_result_dedupe === true) {
8327
- const SAFE_READ_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "BashOutput"]);
8328
- const res = applyToolResultDedupe(parsed.messages, {
8329
- seen: tes.seenContentHashes,
8330
- safeTools: SAFE_READ_TOOLS,
8331
- });
8332
- if (res.changed) parsed.messages = res.messages;
8333
- }
8334
- }
8335
-
8336
- // === Token economy: tool-array transforms (stable ordering, deferral) ===
8337
- if (!conservative && isMainRole && Array.isArray(parsed.tools)) {
8338
- if (te.stable_tool_ordering !== false) {
8339
- parsed.tools = applyStableToolOrdering(parsed.tools);
8340
- }
8341
- if (Array.isArray(te.deferred_tool_names) && te.deferred_tool_names.length > 0) {
8342
- // "Invoked" means any assistant message in the convo has used the tool.
8343
- const invoked = new Set();
8344
- if (Array.isArray(parsed.messages)) {
8345
- for (const m of parsed.messages) {
8346
- if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
8347
- for (const b of m.content) {
8348
- if (b?.type === "tool_use" && typeof b.name === "string") invoked.add(b.name);
8349
- }
8350
- }
8351
- }
8352
- const res = applyToolSchemaDeferral(parsed.tools, {
8353
- deferred: new Set(te.deferred_tool_names),
8354
- invoked,
8355
- });
8356
- parsed.tools = res.tools;
8357
- }
8358
- }
8359
-
8360
- // === Token economy: adaptive thinking zero-out for trivial follow-ups ===
8361
- if (isMainRole && te.adaptive_thinking_zero_simple !== false) {
8362
- applyAdaptiveThinkingZero(parsed);
8363
- }
8364
-
8365
- // QA fix H2: avoid mutating the signature parameter; capture modelId locally
8366
- const modelId = parsed.model || "";
8367
- // Extract first user message text for billing hash computation (cch)
8368
- const firstUserMessage = extractFirstUserMessageText(parsed.messages);
8369
- // Resolve the prompt-cache TTL ONCE so system blocks, tools, and messages
8370
- // all share the SAME ttl. Anthropic processes cache_control blocks in the
8371
- // order tools → system → messages and rejects any ttl='1h' block that comes
8372
- // AFTER a ttl='5m' block. Before this fix the role-scoped 5m downgrade was
8373
- // applied only to tools/messages (resolveCacheTtl in the breakpoint loop)
8374
- // while the system blocks kept the configured 1h — so subagent requests
8375
- // (5m tools, then 1h system) tripped:
8376
- // "system.1.cache_control.ttl: a ttl='1h' cache_control block must not
8377
- // come after a ttl='5m' cache_control block".
8378
- // Threading it through buildSystemPromptBlocks keeps every cache_control ttl
8379
- // consistent (real CC derives the ttl uniformly from querySource).
8380
- const baseCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
8381
- const cachingEnabledForTtl = baseCachePolicy.ttl !== "off" && baseCachePolicy.ttl_supported !== false;
8382
- const resolvedCacheTtl = cachingEnabledForTtl
8383
- ? resolveCacheTtl({
8384
- configuredTtl: baseCachePolicy.ttl || "1h",
8385
- roleScopedTtl: config?.token_economy?.role_scoped_cache_ttl !== false,
8386
- isMainForCache: runtime?.requestRole === "main" || runtime?.requestRole == null,
8387
- isSubagent: runtime?.isSubagent === true,
8388
- env: process.env,
8389
- })
8390
- : baseCachePolicy.ttl;
8391
- const signatureWithModel = {
8392
- ...signature,
8393
- modelId,
8394
- // Override cachePolicy.ttl with the role/subagent-resolved ttl so the
8395
- // system blocks match the tool/message breakpoint ttl (see comment above).
8396
- cachePolicy: { ...baseCachePolicy, ttl: resolvedCacheTtl },
8397
- firstUserMessage,
8398
- antiVerbosity: config?.anti_verbosity,
8399
- // Role-aware system-prompt leaning: for non-main-thread requests (title,
8400
- // small, empty shapes) strip billing identity + CC identity injection.
8401
- // Title-gen path is handled separately by isTitleGeneratorSystemBlocks().
8402
- // Default off — opt-in via `token_economy.lean_system_non_main: true`.
8403
- requestRole: runtime?.requestRole,
8404
- leanNonMain: config?.token_economy?.lean_system_non_main === true,
8405
- // Simple-system-prompt mode for Opus 4.7+ (CC v2.1.133+ parity, gated by
8406
- // GrowthBook `tengu_vellum_lantern` in real CC). Plugin gate: model
8407
- // eligibility + opt-in flag. See buildSystemPromptBlocks for what it
8408
- // strips (anti-verbosity boilerplate only; identity/billing untouched).
8409
- simpleSystemPrompt: config?.token_economy?.simple_system_prompt === true,
8410
- // Workload tag for x-anthropic-billing-header `cc_workload=` segment.
8411
- // Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
8412
- // SDK daemon callers spawning cron subprocesses). Empty string -> omit.
8413
- workload: typeof config?.signature_emulation?.workload === "string" ? config.signature_emulation.workload : "",
8414
- };
8415
- // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
8416
- parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
8417
-
8418
- // Strategy 5 — System prompt tailing: after N turns, trim large system blocks
8419
- // to essential sections only. The model has internalized verbose instructions
8420
- // (shell strategy, package manager tables, delegation protocols) by this point.
8421
- // Preserves: first paragraph (identity/role), lines containing MUST/NEVER/CRITICAL/
8422
- // IMPORTANT, section headers, and short blocks. Drops verbose body paragraphs.
8423
- const tailThreshold = signature.systemPromptTailTurns ?? 6;
8424
- if (signature.systemPromptTailing === true && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
8425
- const maxChars = signature.systemPromptTailMaxChars ?? 2000;
8426
- for (let i = 0; i < parsed.system.length; i++) {
8427
- const block = parsed.system[i];
8428
- if (block.type === "text" && block.text && block.text.length > maxChars * 2) {
8429
- block.text = tailSystemBlock(block.text, maxChars, tailThreshold);
8430
- }
8431
- }
8432
- }
8433
-
8434
- // Token budget (A9): parse NL budget from last user message, inject status block
8435
- if (config?.token_budget?.enabled && Array.isArray(parsed.messages)) {
8436
- const budgetExpr = parseNaturalLanguageBudget(parsed.messages);
8437
- if (budgetExpr > 0) {
8438
- sessionMetrics.tokenBudget.limit = budgetExpr;
8439
- } else if (config.token_budget.default > 0 && sessionMetrics.tokenBudget.limit === 0) {
8440
- sessionMetrics.tokenBudget.limit = config.token_budget.default;
8441
- }
8442
- // If budget is active, inject status into system prompt
8443
- if (sessionMetrics.tokenBudget.limit > 0) {
8444
- const threshold = config.token_budget.completion_threshold ?? 0.9;
8445
- parsed.system = injectTokenBudgetBlock(parsed.system, sessionMetrics.tokenBudget, threshold);
8446
- // Soft stop: if we've exceeded the threshold, cap max_tokens to 1
8447
- if (sessionMetrics.tokenBudget.used >= sessionMetrics.tokenBudget.limit * threshold) {
8448
- parsed.max_tokens = 1;
8449
- }
8450
- }
8451
- }
8452
-
8453
- if (signature.enabled) {
8454
- const currentMetadata =
8455
- parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
8456
- ? parsed.metadata
8457
- : {};
8458
- parsed.metadata = {
8459
- ...currentMetadata,
8460
- ...buildRequestMetadata({
8461
- persistentUserId: runtime.persistentUserId,
8462
- accountId: runtime.accountId,
8463
- sessionId: runtime.sessionId,
8464
- }),
8465
- };
8466
- }
8467
-
8468
- // Cache breakpoint optimization: add cache_control to the last content block
8469
- // of each user/assistant message for maximum prefix caching.
8470
- // Skip for round-robin strategy (cache defeated by account rotation).
8471
- // Skip for title generators / fire-and-forget queries: these are one-shot
8472
- // requests that don't benefit from caching and would pollute the cache pool.
8473
- const isTitleGen = isTitleGeneratorSystemBlocks(parsed.system || []);
8474
- if (
8475
- signature.enabled &&
8476
- signature.cachePolicy?.ttl !== "off" &&
8477
- signature.cachePolicy?.ttl_supported !== false &&
8478
- !isTitleGen
8479
- ) {
8480
- // Strip ALL incoming cache_control from tools and messages to prevent
8481
- // TTL ordering violations (host SDK may set ttl=5m which conflicts with
8482
- // our system prompt ttl=1h). Then add our own to the last user message
8483
- // (matching real CC behavior seen in proxy capture).
8484
- //
8485
- // Role-scoped TTL: real CC's REH(querySource) decides the `ttl:"1h"` field.
8486
- // Decompiled from CC 2.1.154 (function REH, offset 225174828), precedence:
8487
- // 1. FORCE_PROMPT_CACHING_5M => 5m (highest priority)
8488
- // 2. ENABLE_PROMPT_CACHING_1H => 1h
8489
- // 3. no tengu support / overage => 5m
8490
- // 4. querySource ∈ allowlist [repl_main_thread*, sdk, auto_mode, ...] => 1h, else 5m
8491
- // We map step 4 via classifyRequestRole: main (interactive/auto_mode thread)
8492
- // → 1h; side-queries (title-gen, etc.) → 5m. The env vars are honored
8493
- // exactly as CC does, giving the user a manual override + mimicry fidelity.
8494
- // Reuse the request-wide resolved ttl (computed above where
8495
- // signatureWithModel is built) so tools/messages match the system blocks
8496
- // and never trip the 1h-after-5m ordering rule on subagent requests.
8497
- const ccTtl = resolvedCacheTtl;
8498
- if (Array.isArray(parsed.tools) && parsed.tools.length > 0) {
8499
- for (const tool of parsed.tools) {
8500
- if (tool.cache_control) delete tool.cache_control;
8501
- }
8502
- // Adaptive breakpoint placement (opt-in, runtime-driven). Real CC always
8503
- // pins the breakpoint on the last tool because CC controls a STABLE tool
8504
- // array. opencode may reorder/add/remove tools between turns, which moves
8505
- // the "last tool" and invalidates the cached prefix after it. When the
8506
- // detector reports the tool boundary is thrashing while the system prompt
8507
- // is stable, we SKIP the volatile last-tool breakpoint and let the stable
8508
- // system-prompt breakpoint (added by buildSystemPromptBlocks) anchor the
8509
- // cache. Falls back to exact CC behavior when no stability data exists.
8510
- const placeToolBreakpoint = shouldPlaceToolBreakpoint(runtime?.cacheBoundaryStability);
8511
- if (placeToolBreakpoint) {
8512
- parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
8513
- }
8514
- }
8515
- if (Array.isArray(parsed.messages)) {
8516
- for (const msg of parsed.messages) {
8517
- if (Array.isArray(msg.content)) {
8518
- for (const block of msg.content) {
8519
- // CONTRACT GUARD: `thinking` / `redacted_thinking` blocks MUST be
8520
- // round-tripped byte-identical to the model's original response
8521
- // (Anthropic extended-thinking rules). ANY mutation — including
8522
- // `delete block.cache_control` — triggers the 400 error
8523
- // "thinking or redacted_thinking blocks in the latest assistant
8524
- // message cannot be modified". `cache_control` is not even a valid
8525
- // field on a thinking block, so we never strip nor add it here;
8526
- // we leave the block exactly as received. This is required for
8527
- // adaptive-thinking models (Opus 4.6/4.7/4.8, Sonnet 4.6) on
8528
- // tool-continuation turns. See docs/mimese-http-header-system-prompt.md.
8529
- if (!block || typeof block !== "object") continue;
8530
- if (block.type === "thinking" || block.type === "redacted_thinking") {
8531
- continue;
8532
- }
8533
- if (block.cache_control) delete block.cache_control;
8534
- }
8535
- }
8536
- }
8537
- // Add cache_control to last user message (real CC does this).
8538
- // (User messages never contain thinking blocks, so the guard above
8539
- // does not affect this breakpoint.)
8540
- for (let i = parsed.messages.length - 1; i >= 0; i--) {
8541
- const msg = parsed.messages[i];
8542
- if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
8543
- const lastBlock = msg.content[msg.content.length - 1];
8544
- if (lastBlock && typeof lastBlock === "object") {
8545
- lastBlock.cache_control = { type: "ephemeral", ttl: ccTtl };
8546
- }
8547
- break;
8548
- }
8549
- }
8550
- }
8551
-
8552
- // Tool name sanitization: Anthropic's server blocklists known non-CC tool names.
8553
- // opencode uses lowercase names while CC uses PascalCase. While only "todowrite"
8554
- // is currently confirmed blocklisted, we rename ALL core opencode tools to match
8555
- // CC's naming convention as a preventive measure against future blocklist additions.
8556
- const OC_TO_CC_TOOL_NAMES = {
8557
- bash: "Bash",
8558
- read: "Read",
8559
- glob: "Glob",
8560
- grep: "Grep",
8561
- edit: "Edit",
8562
- write: "Write",
8563
- webfetch: "WebFetch",
8564
- todowrite: "TodoWrite",
8565
- skill: "Skill",
8566
- task: "Task",
8567
- compress: "Compress",
8568
- };
8569
- if (Array.isArray(parsed.tools)) {
8570
- for (const tool of parsed.tools) {
8571
- if (tool.name && OC_TO_CC_TOOL_NAMES[tool.name]) {
8572
- tool.name = OC_TO_CC_TOOL_NAMES[tool.name];
8573
- }
8574
- }
8575
- }
8576
- // Also rename in tool_use blocks in messages (assistant responses referencing the tool)
8577
- if (Array.isArray(parsed.messages)) {
8578
- for (const msg of parsed.messages) {
8579
- if (!Array.isArray(msg.content)) continue;
8580
- for (const block of msg.content) {
8581
- if (block.type === "tool_use" && block.name && OC_TO_CC_TOOL_NAMES[block.name]) {
8582
- block.name = OC_TO_CC_TOOL_NAMES[block.name];
8583
- }
8584
- }
8585
- }
8586
- }
8587
- // Track which tools the model has used (from assistant tool_use blocks).
8588
- // Names are already CC PascalCase after renaming above.
8589
- if (Array.isArray(parsed.messages)) {
8590
- for (const msg of parsed.messages) {
8591
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
8592
- for (const block of msg.content) {
8593
- if (block.type === "tool_use" && block.name) {
8594
- runtime.usedTools.add(block.name);
8595
- }
8596
- }
8597
- }
8598
- }
8599
- }
8600
-
8601
- // Strategy 4 — Adaptive tool set: after turn 3, defer non-core tools that
8602
- // the model hasn't used yet. This saves schema bytes on turns where tools
8603
- // are unlikely to be needed. Core tools stay eager. Resets on /clear.
8604
- if (
8605
- Array.isArray(parsed.tools) &&
8606
- signature.adaptiveToolSet !== false &&
8607
- runtime.turns >= 3 &&
8608
- parsed.model &&
8609
- !/claude-3-|haiku/i.test(parsed.model)
8610
- ) {
8611
- const used = runtime.usedTools;
8612
- for (const tool of parsed.tools) {
8613
- if (tool.name && !used.has(tool.name) && !CORE_TOOL_NAMES.has(tool.name)) {
8614
- tool.defer_loading = true;
8615
- }
8616
- }
8617
- }
8618
-
8619
- // Tool description compaction: apply the same compaction logic used for
8620
- // system prompts (strip examples, collapse whitespace, dedup lines) to tool
8621
- // descriptions. The top 4 tools (Bash 10.6K, TodoWrite 9.7K, Task 5.5K,
8622
- // Compress 4.5K) account for 30KB. Compaction typically saves 30-50%.
8623
- if (Array.isArray(parsed.tools) && signature.toolDescriptionCompaction !== false) {
8624
- for (const tool of parsed.tools) {
8625
- if (tool.description && tool.description.length > 500) {
8626
- tool.description = compactToolDescription(tool.description);
8627
- }
8628
- }
8629
- }
5850
+ * @param {"anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle"} provider
5851
+ * @param {string[]} [customBetas]
5852
+ * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
5853
+ * @param {string} [requestPath]
5854
+ * @param {boolean} [hasFileReferences]
5855
+ * @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
5856
+ * @param {boolean} [fastModeActive] - When true, emits FAST_MODE_BETA_FLAG (fast-mode-2026-02-01).
5857
+ * Must be derived structurally from the already-transformed outgoing body (body.includes('"speed":"fast"')).
5858
+ * Only passed from the buildRequestHeaders call site (after body transform). The pre-transform
5859
+ * computedBetaHeader call site (index.mjs:2905) leaves this undefined so the latch never
5860
+ * accumulates fast-mode independently of actual body content.
5861
+ * @returns {string}
5862
+ */
5863
+ // Mirrors CC's Kw(model) effort eligibility: returns false for claude-3-* and the
5864
+ // explicit older 4.x exclusion set (opus-4-0/4-1, sonnet-4-0/4-5, haiku-4-5), true
5865
+ // for every other model (effort-capable: Opus 4.5/4.6/4.7/4.8, Sonnet 4.6, etc.).
8630
5866
 
8631
- // MCP tool deferral: mark non-core tools with defer_loading: true.
8632
- // CC defers all MCP tools by default — the API omits their full schemas from token
8633
- // counting, only sending the tool name. When the model needs a deferred tool, it uses
8634
- // tool_reference to load the schema on demand. This saves ~20KB per turn.
8635
- // Core tools (OC_TO_CC_TOOL_NAMES) are always eager-loaded.
8636
- if (
8637
- Array.isArray(parsed.tools) &&
8638
- signature.toolDeferral !== false &&
8639
- parsed.model &&
8640
- !/claude-3-|haiku/i.test(parsed.model)
8641
- ) {
8642
- const coreToolNames = new Set(Object.values(OC_TO_CC_TOOL_NAMES));
8643
- for (const tool of parsed.tools) {
8644
- if (tool.name && !coreToolNames.has(tool.name)) {
8645
- tool.defer_loading = true;
8646
- }
8647
- }
8648
- }
5867
+ /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
8649
5868
 
8650
- // Task budgets: when the task-budgets beta is active, preserve or inject output_config.
8651
- // The beta unlocks output_config.max_output_tokens for per-task budget control.
8652
- // Model-router compatibility: the beta header + output_config body are forwarded as-is.
8653
- if (betaHeader && betaHeader.includes("task-budgets-2026-03-13")) {
8654
- if (!parsed.output_config) {
8655
- // Default: set a reasonable per-task output budget for long-running agentic tasks.
8656
- // Claude Code tasks typically need generous output budgets.
8657
- parsed.output_config = { max_output_tokens: 16384 };
8658
- }
8659
- }
5869
+ /**
5870
+ * Map budgetTokens to an effort level.
5871
+ * Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
5872
+ * @param {number} budgetTokens
5873
+ * @returns {ThinkingEffort}
5874
+ */
5875
+ function budgetTokensToEffort(budgetTokens) {
5876
+ if (budgetTokens <= 1024) return "low";
5877
+ if (budgetTokens <= 8000) return "medium";
5878
+ return "high";
5879
+ }
8660
5880
 
8661
- // Context hint (CC v2.1.110+): pair the context-hint-2026-04-09 beta with
8662
- // the body field context_hint:{enabled:true}. Extracted from CC bundle
8663
- // buildRequestParams(): `{betaHeader, body:{context_hint:{enabled:!0}}}`.
8664
- // When active, the server responds with optional hints suggesting which
8665
- // messages to drop for token-efficient context compaction.
8666
- if (betaHeader && betaHeader.includes("context-hint-2026-04-09") && !parsed.context_hint) {
8667
- parsed.context_hint = { enabled: true };
8668
- }
5881
+ // QA fix L3: budgetTokensToEffort() removed — dead code, never called
5882
+ // QA fix L4: isValidEffort() removed — dead code, never called
8669
5883
 
8670
- // Fast mode: inject speed parameter for fast-mode-eligible models.
8671
- // Per Anthropic fast-mode docs, `speed: "fast"` (beta fast-mode-2026-02-01) is
8672
- // supported on Opus 4.6, Opus 4.7, and Opus 4.8 (research preview). Opus 4.7
8673
- // is the /fast default in real Claude Code v2.1.142+. Sonnet is NOT eligible.
8674
- // NOTE: switching speed invalidates system + message prompt caches (per
8675
- // Anthropic fast-mode docs), so only flip it deliberately.
8676
- // When the selected account's fast pool is cooling down (a prior fast 429),
8677
- // suppress speed:"fast" entirely so this turn runs at standard speed on the
8678
- // same account rather than re-hitting the exhausted fast pool.
8679
- const fastPoolAvailable = !signature.fastRateLimited;
8680
- const isFastModeEligibleModel = (m) =>
8681
- fastPoolAvailable && (isOpus46Model(m) || isOpus47Model(m) || isOpus48Model(m));
8682
- const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
8683
- let fastModeAutoApplied = false;
8684
- if (
8685
- !fastModeEnabled &&
8686
- te.fast_mode_auto === true &&
8687
- isMainRole &&
8688
- parsed.model &&
8689
- isFastModeEligibleModel(parsed.model) &&
8690
- Array.isArray(parsed.messages) &&
8691
- parsed.messages.length >= 2
8692
- ) {
8693
- // Simple exchange heuristic: last user message is short, no tool_result,
8694
- // no file references. Suggests a follow-up question that doesn't need
8695
- // deep reasoning.
8696
- const last = parsed.messages[parsed.messages.length - 1];
8697
- if (last && last.role === "user") {
8698
- let txt = "";
8699
- let hasToolResult = false;
8700
- if (typeof last.content === "string") txt = last.content;
8701
- else if (Array.isArray(last.content)) {
8702
- for (const b of last.content) {
8703
- if (b?.type === "tool_result") hasToolResult = true;
8704
- if (b?.type === "text" && typeof b.text === "string") txt += b.text;
8705
- }
8706
- }
8707
- if (!hasToolResult && txt.length < 240 && !/\bfile:|\.md\b|\.mjs\b|\.ts\b/i.test(txt)) {
8708
- fastModeAutoApplied = true;
8709
- }
8710
- }
8711
- }
8712
- if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isFastModeEligibleModel(parsed.model)) {
8713
- parsed.speed = "fast";
8714
- }
5884
+ /**
5885
+ * Normalise the `thinking` block in the request body for the target model:
5886
+ * - Opus 4.6 / 4.7 / 4.8 / Sonnet 4.6 (adaptive thinking): produces
5887
+ * `{ type: "adaptive" }`. This is REQUIRED for Opus 4.7/4.8 — manual
5888
+ * `{ type: "enabled", budget_tokens }` returns a 400 on those models.
5889
+ * - Older models: passes the existing thinking block through unchanged.
5890
+ *
5891
+ * @param {any} thinking
5892
+ * @param {string} model
5893
+ * @returns {any}
5894
+ */
5895
+ /**
5896
+ * Map Node.js platform to Stainless OS header value.
5897
+ * @param {NodeJS.Platform} value
5898
+ * @returns {string}
5899
+ */
8715
5900
 
8716
- // Guard: repair orphaned tool_use blocks anywhere in the message array.
8717
- // The Anthropic API requires that every assistant message containing tool_use
8718
- // blocks is immediately followed by a user message with matching tool_result
8719
- // blocks. When OpenCode crashes/hangs mid-tool-execution, the conversation
8720
- // state may be saved with unpaired tool_use blocks. This causes:
8721
- // "messages.N: `tool_use` ids were found without `tool_result` blocks
8722
- // immediately after: toolu_XXXXX"
8723
- // We scan the full array and synthesize missing tool_result messages.
8724
- if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
8725
- parsed.messages = repairOrphanedToolUseBlocks(parsed.messages);
8726
-
8727
- // Also ensure the array never ends with an assistant message (prefill guard).
8728
- const lastMsg = parsed.messages[parsed.messages.length - 1];
8729
- if (lastMsg && lastMsg.role === "assistant") {
8730
- const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
8731
- const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
8732
- if (toolUseBlocks.length > 0) {
8733
- parsed.messages.push({
8734
- role: "user",
8735
- content: toolUseBlocks.map((tu) => ({
8736
- type: "tool_result",
8737
- tool_use_id: tu.id,
8738
- content: "[Result unavailable — conversation was restructured]",
8739
- })),
8740
- });
8741
- } else {
8742
- parsed.messages.push({
8743
- role: "user",
8744
- content: [{ type: "text", text: "Continue." }],
8745
- });
8746
- }
8747
- }
8748
- }
5901
+ /**
5902
+ * Normalize Node.js arch to Stainless arch header value.
5903
+ * @param {string} value
5904
+ * @returns {string}
5905
+ */
8749
5906
 
8750
- return JSON.stringify(parsed);
5907
+ /**
5908
+ * Resolve latest claude-code package version from npm registry.
5909
+ * Returns null on timeout/network/parse failures.
5910
+ * @param {number} timeoutMs
5911
+ * @returns {Promise<string | null>}
5912
+ */
5913
+ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
5914
+ const controller = new AbortController();
5915
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
5916
+ try {
5917
+ const response = await fetch(CLAUDE_CODE_NPM_LATEST_URL, {
5918
+ method: "GET",
5919
+ headers: { accept: "application/json" },
5920
+ signal: controller.signal,
5921
+ });
5922
+ if (!response.ok) return null;
5923
+ const data = await response.json();
5924
+ if (!data || typeof data !== "object") return null;
5925
+ return typeof data.version === "string" && data.version ? data.version : null;
8751
5926
  } catch {
8752
- // ignore parse errors
8753
- return body;
5927
+ return null;
5928
+ } finally {
5929
+ clearTimeout(timeout);
8754
5930
  }
8755
5931
  }
8756
5932
 
5933
+ /**
5934
+ * Build request headers from input and init, applying OAuth requirements.
5935
+ * Preserves behaviors D1-D7.
5936
+ *
5937
+ * @param {any} input
5938
+ * @param {Record<string, any>} requestInit
5939
+ * @param {string} accessToken
5940
+ * @param {string | undefined} requestBody
5941
+ * @param {URL | null} requestUrl
5942
+ * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[], sessionId?: string}} signature
5943
+ * @returns {Headers}
5944
+ */
5945
+
5946
+ /**
5947
+ * Resolve max_tokens for a request based on output cap configuration.
5948
+ * If the caller specified max_tokens, it is preserved. Otherwise, defaults
5949
+ * to 8K and escalates to 64K after an output truncation (stop_reason: "max_tokens").
5950
+ *
5951
+ * @param {Record<string, any>} body - Parsed request body
5952
+ * @param {import('./lib/config.mjs').AnthropicAuthConfig} config
5953
+ * @returns {number | undefined} Resolved max_tokens value, or undefined for passthrough
5954
+ */
5955
+
8757
5956
  /**
8758
5957
  * Transform the request URL: add ?beta=true to /v1/messages and /v1/messages/count_tokens.
8759
5958
  * Preserves behaviors F1-F3.
@@ -8815,234 +6014,9 @@ function transformRequestUrl(input) {
8815
6014
  * @property {number} cacheReadTokens
8816
6015
  * @property {number} cacheWriteTokens
8817
6016
  * @property {number} [webSearchRequests]
6017
+ * @property {string} [lastStopReason]
8818
6018
  */
8819
6019
 
8820
- /**
8821
- * Update running usage stats from a parsed SSE event.
8822
- * @param {any} parsed
8823
- * @param {UsageStats} stats
8824
- */
8825
- function extractUsageFromSSEEvent(parsed, stats) {
8826
- // message_delta: cumulative usage (preferred, overwrites)
8827
- if (parsed?.type === "message_delta" && parsed.usage) {
8828
- const u = parsed.usage;
8829
- if (typeof u.input_tokens === "number") stats.inputTokens = u.input_tokens;
8830
- if (typeof u.output_tokens === "number") stats.outputTokens = u.output_tokens;
8831
- if (typeof u.cache_read_input_tokens === "number") stats.cacheReadTokens = u.cache_read_input_tokens;
8832
- if (typeof u.cache_creation_input_tokens === "number") stats.cacheWriteTokens = u.cache_creation_input_tokens;
8833
- // Web search requests (server tool usage)
8834
- if (typeof u.server_tool_use?.web_search_requests === "number") {
8835
- stats.webSearchRequests = u.server_tool_use.web_search_requests;
8836
- }
8837
- // Capture stop_reason from message_delta for output cap escalation
8838
- if (parsed.delta?.stop_reason) {
8839
- sessionMetrics.lastStopReason = parsed.delta.stop_reason;
8840
- }
8841
- return;
8842
- }
8843
-
8844
- // message_start: initial usage (only set if we haven't seen message_delta yet)
8845
- if (parsed?.type === "message_start" && parsed.message?.usage) {
8846
- const u = parsed.message.usage;
8847
- if (stats.inputTokens === 0 && typeof u.input_tokens === "number") {
8848
- stats.inputTokens = u.input_tokens;
8849
- }
8850
- if (stats.cacheReadTokens === 0 && typeof u.cache_read_input_tokens === "number") {
8851
- stats.cacheReadTokens = u.cache_read_input_tokens;
8852
- }
8853
- if (stats.cacheWriteTokens === 0 && typeof u.cache_creation_input_tokens === "number") {
8854
- stats.cacheWriteTokens = u.cache_creation_input_tokens;
8855
- }
8856
- }
8857
- }
8858
-
8859
- /**
8860
- * Extract the combined SSE data payload from one event block.
8861
- * @param {string} eventBlock
8862
- * @returns {string | null}
8863
- */
8864
- function getSSEDataPayload(eventBlock) {
8865
- if (!eventBlock) return null;
8866
-
8867
- const dataLines = [];
8868
- for (const line of eventBlock.split("\n")) {
8869
- if (!line.startsWith("data:")) continue;
8870
- // QA fix: SSE spec says strip only a single leading space after "data:", not all whitespace
8871
- const raw = line.slice(5);
8872
- dataLines.push(raw.startsWith(" ") ? raw.slice(1) : raw);
8873
- }
8874
-
8875
- if (dataLines.length === 0) return null;
8876
- const payload = dataLines.join("\n");
8877
- if (!payload || payload === "[DONE]") return null;
8878
- return payload;
8879
- }
8880
-
8881
- /**
8882
- * Parse one SSE event payload and return account-error details if present.
8883
- * @param {any} parsed
8884
- * @returns {{reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean} | null}
8885
- */
8886
- function getMidStreamAccountError(parsed) {
8887
- if (!parsed || parsed.type !== "error" || !parsed.error) {
8888
- return null;
8889
- }
8890
-
8891
- const errorBody = {
8892
- error: {
8893
- type: String(parsed.error.type || ""),
8894
- message: String(parsed.error.message || ""),
8895
- },
8896
- };
8897
-
8898
- // Mid-stream errors do not include a reliable HTTP status. Use 400-style
8899
- // body parsing to identify account-specific errors.
8900
- if (!isAccountSpecificError(400, errorBody)) {
8901
- return null;
8902
- }
8903
-
8904
- const reason = parseRateLimitReason(400, errorBody);
8905
-
8906
- return {
8907
- reason,
8908
- invalidateToken: reason === "AUTH_FAILED",
8909
- };
8910
- }
8911
-
8912
- /**
8913
- * Strip `mcp_` prefix from tool_use `name` fields in SSE data lines.
8914
- * Only modifies `name` values inside content blocks with `"type": "tool_use"`.
8915
- * Non-JSON lines and text blocks are left untouched.
8916
- *
8917
- * @param {string} text - Raw SSE chunk text (may contain multiple lines)
8918
- * @returns {string}
8919
- */
8920
- function stripMcpPrefixFromSSE(text) {
8921
- return text.replace(/^data:\s*(.+)$/gm, (_match, jsonStr) => {
8922
- try {
8923
- const parsed = JSON.parse(jsonStr);
8924
- if (stripMcpPrefixFromParsedEvent(parsed)) {
8925
- return `data: ${JSON.stringify(parsed)}`;
8926
- }
8927
- } catch {
8928
- // Not valid JSON — pass through unchanged.
8929
- }
8930
- return _match;
8931
- });
8932
- }
8933
-
8934
- /**
8935
- * Reverse map: CC PascalCase → opencode lowercase for response stream.
8936
- * Built from the outgoing OC_TO_CC_TOOL_NAMES map (defined in transformRequestBody).
8937
- * Must stay in sync with that map.
8938
- */
8939
- const CC_TO_OC_TOOL_NAMES = {
8940
- Bash: "bash",
8941
- Read: "read",
8942
- Glob: "glob",
8943
- Grep: "grep",
8944
- Edit: "edit",
8945
- Write: "write",
8946
- WebFetch: "webfetch",
8947
- TodoWrite: "todowrite",
8948
- Skill: "skill",
8949
- Task: "task",
8950
- Compress: "compress",
8951
- };
8952
-
8953
- /**
8954
- * Reverse-map a tool name from CC PascalCase back to opencode lowercase,
8955
- * and strip `mcp_` prefix if present. Returns the original name if no mapping exists.
8956
- * @param {string} name
8957
- * @returns {string}
8958
- */
8959
- function reverseMapToolName(name) {
8960
- if (CC_TO_OC_TOOL_NAMES[name]) return CC_TO_OC_TOOL_NAMES[name];
8961
- if (name.startsWith("mcp_")) return name.slice(4);
8962
- return name;
8963
- }
8964
-
8965
- /**
8966
- * Mutate a parsed SSE event object, reversing tool name renames (CC PascalCase
8967
- * → opencode lowercase) and removing `mcp_` prefix from tool_use name fields.
8968
- * Returns true if any modification was made.
8969
- *
8970
- * @param {any} parsed
8971
- * @returns {boolean}
8972
- */
8973
- function stripMcpPrefixFromParsedEvent(parsed) {
8974
- if (!parsed || typeof parsed !== "object") return false;
8975
-
8976
- let modified = false;
8977
-
8978
- // content_block_start: { content_block: { type: "tool_use"|"tool_reference", name: "..." } }
8979
- if (
8980
- parsed.content_block &&
8981
- (parsed.content_block.type === "tool_use" || parsed.content_block.type === "tool_reference") &&
8982
- typeof parsed.content_block.name === "string"
8983
- ) {
8984
- const mapped = reverseMapToolName(parsed.content_block.name);
8985
- if (mapped !== parsed.content_block.name) {
8986
- parsed.content_block.name = mapped;
8987
- modified = true;
8988
- }
8989
- }
8990
-
8991
- // message_start: { message: { content: [{ type: "tool_use"|"tool_reference", name: "..." }] } }
8992
- if (parsed.message && Array.isArray(parsed.message.content)) {
8993
- for (const block of parsed.message.content) {
8994
- if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
8995
- const mapped = reverseMapToolName(block.name);
8996
- if (mapped !== block.name) {
8997
- block.name = mapped;
8998
- modified = true;
8999
- }
9000
- }
9001
- }
9002
- }
9003
-
9004
- // Top-level content array (non-streaming responses forwarded through SSE)
9005
- if (Array.isArray(parsed.content)) {
9006
- for (const block of parsed.content) {
9007
- if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
9008
- const mapped = reverseMapToolName(block.name);
9009
- if (mapped !== block.name) {
9010
- block.name = mapped;
9011
- modified = true;
9012
- }
9013
- }
9014
- }
9015
- }
9016
-
9017
- return modified;
9018
- }
9019
-
9020
- /**
9021
- * Resolve the byte-stream idle timeout in milliseconds.
9022
- *
9023
- * Parity with Claude Code's `tengu_byte_stream_idle_timeout_ms`: a watchdog that
9024
- * fires when the upstream SSE stream produces no bytes for N ms, so a stalled /
9025
- * half-dead connection surfaces as a fast error instead of an indefinite hang.
9026
- *
9027
- * Resolution order: env `OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS` →
9028
- * `config.streaming.idle_timeout_ms` → 0 (disabled). Disabled by default because
9029
- * a too-aggressive value could abort a legitimate long generation; Anthropic SSE
9030
- * emits periodic `ping` events, so a multi-minute silence is the safe signal.
9031
- *
9032
- * @param {any} cfg
9033
- * @returns {number} timeout in ms, or 0 to disable
9034
- */
9035
- function resolveStreamIdleTimeoutMs(cfg) {
9036
- const envRaw = process.env.OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS;
9037
- if (envRaw != null && envRaw !== "") {
9038
- const n = Number(envRaw);
9039
- if (Number.isFinite(n) && n >= 0) return Math.floor(n);
9040
- }
9041
- const c = cfg?.streaming?.idle_timeout_ms;
9042
- if (typeof c === "number" && Number.isFinite(c) && c >= 0) return Math.floor(c);
9043
- return 0;
9044
- }
9045
-
9046
6020
  /**
9047
6021
  * Wrap a response body stream to strip mcp_ prefix from tool names,
9048
6022
  * extract token usage stats from SSE events, and detect mid-stream
@@ -9053,166 +6027,20 @@ function resolveStreamIdleTimeoutMs(cfg) {
9053
6027
  * @param {((stats: UsageStats) => void) | null} [onUsage] - Called when stream ends with final usage
9054
6028
  * @param {((details: {reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [onAccountError]
9055
6029
  * Called if a mid-stream error looks account-specific
6030
+ * @param {string} [correlationId]
9056
6031
  * @returns {Response}
9057
6032
  */
9058
- function transformResponse(response, onUsage, onAccountError) {
6033
+ function transformResponse(response, onUsage, onAccountError, correlationId) {
9059
6034
  if (!response.body) return response;
9060
-
9061
- const reader = response.body.getReader();
9062
- const decoder = new TextDecoder();
9063
- const encoder = new TextEncoder();
9064
- const EMPTY_CHUNK = new Uint8Array();
9065
-
9066
- // Byte-stream idle-timeout watchdog (parity with CC tengu_byte_stream_idle_timeout_ms).
9067
- // 0 = disabled (default). See resolveStreamIdleTimeoutMs.
9068
6035
  const idleTimeoutMs = resolveStreamIdleTimeoutMs(_pluginConfig);
9069
-
9070
- /** @type {UsageStats} */
9071
- const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
9072
- let sseBuffer = "";
9073
- let sseRewriteBuffer = "";
9074
- let accountErrorHandled = false;
9075
-
9076
- /**
9077
- * Process buffered SSE event blocks.
9078
- * @param {boolean} flush
9079
- */
9080
- function processSSEBuffer(flush = false) {
9081
- while (true) {
9082
- const boundary = sseBuffer.indexOf("\n\n");
9083
-
9084
- if (boundary === -1) {
9085
- if (!flush) return;
9086
- if (!sseBuffer.trim()) {
9087
- sseBuffer = "";
9088
- return;
9089
- }
9090
- }
9091
-
9092
- const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
9093
- sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
9094
-
9095
- const payload = getSSEDataPayload(eventBlock);
9096
- if (!payload) {
9097
- if (boundary === -1) return;
9098
- continue;
9099
- }
9100
-
9101
- try {
9102
- const parsed = JSON.parse(payload);
9103
-
9104
- if (onUsage) {
9105
- extractUsageFromSSEEvent(parsed, stats);
9106
- }
9107
-
9108
- if (onAccountError && !accountErrorHandled) {
9109
- const details = getMidStreamAccountError(parsed);
9110
- if (details) {
9111
- accountErrorHandled = true;
9112
- onAccountError(details);
9113
- }
9114
- }
9115
- } catch {
9116
- // Ignore malformed event payloads.
9117
- }
9118
-
9119
- if (boundary === -1) return;
9120
- }
9121
- }
9122
-
9123
- /**
9124
- * Rewrite complete SSE lines while preserving chunk boundaries for streaming.
9125
- * Buffers trailing partial lines to avoid parsing split JSON payloads.
9126
- * @param {string} chunk
9127
- * @param {boolean} [flush]
9128
- * @returns {string}
9129
- */
9130
- function rewriteSSEChunk(chunk, flush = false) {
9131
- sseRewriteBuffer += chunk;
9132
-
9133
- if (!flush) {
9134
- const boundary = sseRewriteBuffer.lastIndexOf("\n");
9135
- if (boundary === -1) return "";
9136
- const complete = sseRewriteBuffer.slice(0, boundary + 1);
9137
- sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
9138
- return stripMcpPrefixFromSSE(complete);
9139
- }
9140
-
9141
- if (!sseRewriteBuffer) return "";
9142
- const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
9143
- sseRewriteBuffer = "";
9144
- return finalText;
9145
- }
9146
-
9147
- const stream = new ReadableStream({
9148
- async pull(controller) {
9149
- let readResult;
9150
- if (idleTimeoutMs > 0) {
9151
- /** @type {ReturnType<typeof setTimeout> | undefined} */
9152
- let idleTimer;
9153
- try {
9154
- readResult = await Promise.race([
9155
- reader.read(),
9156
- new Promise((_resolve, reject) => {
9157
- idleTimer = setTimeout(
9158
- () => reject(new Error(`stream idle timeout: no bytes for ${idleTimeoutMs}ms`)),
9159
- idleTimeoutMs,
9160
- );
9161
- }),
9162
- ]);
9163
- } catch (err) {
9164
- // Idle timeout (or read error): cancel the upstream reader and surface a
9165
- // clear error so the consumer can retry instead of waiting on a dead
9166
- // connection. Mirrors CC's stream-idle watchdog (no silent hang).
9167
- try {
9168
- await reader.cancel();
9169
- } catch {
9170
- // Reader may already be released; nothing to do.
9171
- }
9172
- controller.error(err instanceof Error ? err : new Error(String(err)));
9173
- return;
9174
- } finally {
9175
- clearTimeout(idleTimer);
9176
- }
9177
- } else {
9178
- readResult = await reader.read();
9179
- }
9180
- const { done, value } = readResult;
9181
- if (done) {
9182
- processSSEBuffer(true);
9183
-
9184
- const rewrittenTail = rewriteSSEChunk("", true);
9185
- if (rewrittenTail) {
9186
- controller.enqueue(encoder.encode(rewrittenTail));
9187
- }
9188
-
9189
- if (
9190
- onUsage &&
9191
- (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)
9192
- ) {
9193
- onUsage(stats);
9194
- }
9195
- controller.close();
9196
- return;
9197
- }
9198
-
9199
- const text = decoder.decode(value, { stream: true });
9200
-
9201
- if (onUsage || onAccountError) {
9202
- // Normalize CRLF for parser only; preserve original bytes for passthrough.
9203
- sseBuffer += text.replace(/\r\n/g, "\n");
9204
- processSSEBuffer(false);
9205
- }
9206
-
9207
- const rewrittenText = rewriteSSEChunk(text, false);
9208
- if (rewrittenText) {
9209
- controller.enqueue(encoder.encode(rewrittenText));
9210
- } else {
9211
- // Keep the pull/read loop progressing when this chunk only extends a
9212
- // partial line buffered for later rewrite.
9213
- controller.enqueue(EMPTY_CHUNK);
9214
- }
9215
- },
6036
+ const captureEnabled = _pluginConfig?.token_economy?.debug_dump_bodies === true && Boolean(correlationId);
6037
+ const stream = createTransformedSSEStream(response, {
6038
+ onUsage,
6039
+ onAccountError,
6040
+ correlationId,
6041
+ idleTimeoutMs,
6042
+ captureEnabled,
6043
+ writeSseCapture,
9216
6044
  });
9217
6045
 
9218
6046
  // Inject cache transparency headers (session-level, available before stream completes).
@@ -9485,26 +6313,6 @@ function parseCommandArgs(raw) {
9485
6313
  * @param {any} body - Parsed JSON body
9486
6314
  * @returns {string[]}
9487
6315
  */
9488
- function extractFileIds(body) {
9489
- const ids = [];
9490
- if (!body || typeof body !== "object") return ids;
9491
- // QA fix L-depth: cap recursion depth to prevent stack overflow on pathological payloads
9492
- const MAX_DEPTH = 20;
9493
- function walk(obj, depth) {
9494
- if (depth > MAX_DEPTH) return;
9495
- if (Array.isArray(obj)) {
9496
- for (const item of obj) walk(item, depth + 1);
9497
- } else if (obj && typeof obj === "object") {
9498
- if (obj.source?.file_id) ids.push(obj.source.file_id);
9499
- for (const val of Object.values(obj)) {
9500
- if (val && typeof val === "object") walk(val, depth + 1);
9501
- }
9502
- }
9503
- }
9504
- walk(body.messages, 0);
9505
- walk(body.system, 0);
9506
- return ids;
9507
- }
9508
6316
 
9509
6317
  // Internals exposed for tests only. Do not consume from production code paths.
9510
6318
  //
@@ -9539,10 +6347,10 @@ AnthropicAuthPlugin.__testing__ = {
9539
6347
  // exposed for experimental.session.summarize integration tests
9540
6348
  runHaikuSessionSummarize,
9541
6349
  get cachedCCPrompt() {
9542
- return cachedCCPrompt;
6350
+ return getCachedCCPrompt();
9543
6351
  },
9544
6352
  resetCachedCCPrompt() {
9545
- cachedCCPrompt = null;
6353
+ resetCachedCCPrompt();
9546
6354
  },
9547
6355
  SUBAGENT_CC_ANCHOR,
9548
6356
  CLAUDE_CODE_IDENTITY_STRING,