opencode-anthropic-fix 0.1.31 → 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.
- package/README.md +2 -0
- package/cli.mjs +42 -35
- package/index.mjs +399 -3591
- package/lib/config.mjs +9 -10
- package/lib/config.test.mjs +298 -1
- package/lib/diagnose.mjs +184 -0
- package/lib/diagnose.test.mjs +122 -0
- package/lib/env.mjs +19 -0
- package/lib/env.test.mjs +25 -0
- package/lib/mimicry/cache.mjs +62 -0
- package/lib/mimicry/cache.test.mjs +112 -0
- package/lib/mimicry/headers.mjs +530 -0
- package/lib/mimicry/headers.test.mjs +50 -0
- package/lib/mimicry/models.mjs +146 -0
- package/lib/mimicry/models.test.mjs +132 -0
- package/lib/mimicry/request-body.mjs +684 -0
- package/lib/mimicry/request-body.test.mjs +34 -0
- package/lib/mimicry/request-helpers.mjs +243 -0
- package/lib/mimicry/request-helpers.test.mjs +155 -0
- package/lib/mimicry/response-stream.mjs +429 -0
- package/lib/mimicry/response-stream.test.mjs +127 -0
- package/lib/mimicry/system-prompt.mjs +638 -0
- package/lib/mimicry/system-prompt.test.mjs +73 -0
- package/lib/oauth.mjs +1 -1
- package/lib/oauth.test.mjs +8 -4
- package/lib/redact.mjs +147 -0
- package/lib/redact.test.mjs +154 -0
- package/lib/refresh-lock.test.mjs +52 -0
- package/lib/retry/overload-loop.mjs +83 -0
- package/lib/retry/overload-loop.test.mjs +107 -0
- package/lib/session-metrics.mjs +75 -0
- package/lib/session-metrics.test.mjs +67 -0
- package/lib/storage.test.mjs +82 -0
- package/lib/token-economy/microcompact.mjs +35 -0
- package/lib/token-economy/microcompact.test.mjs +28 -0
- package/lib/token-economy/transforms.mjs +637 -0
- package/lib/token-economy/transforms.test.mjs +192 -0
- package/lib/tuning.mjs +70 -0
- package/lib/tuning.test.mjs +89 -0
- 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
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
|
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:",
|
|
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
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
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
|
-
|
|
4391
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5844
|
-
*
|
|
5845
|
-
*
|
|
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
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
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
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
5683
|
+
function shouldDebugSystemPrompt() {
|
|
5684
|
+
return isTruthyEnv(process.env[DEBUG_SYSTEM_PROMPT_ENV]);
|
|
5685
|
+
}
|
|
5926
5686
|
|
|
5927
5687
|
/**
|
|
5928
|
-
*
|
|
5929
|
-
*
|
|
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
|
-
*
|
|
5938
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
6068
|
-
}
|
|
5699
|
+
/**
|
|
5700
|
+
* @returns {Record<string, string>}
|
|
5701
|
+
*/
|
|
6069
5702
|
|
|
6070
5703
|
/**
|
|
6071
|
-
*
|
|
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
|
|
6081
|
-
|
|
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
|
-
*
|
|
6089
|
-
*
|
|
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
|
|
6101
|
-
|
|
6102
|
-
if (!
|
|
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
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
if (
|
|
6113
|
-
|
|
6114
|
-
|
|
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
|
-
*
|
|
6138
|
-
*
|
|
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
|
-
*
|
|
6163
|
-
*
|
|
6164
|
-
*
|
|
6165
|
-
* @param {any[]} tools
|
|
6166
|
-
* @returns {any[]}
|
|
5737
|
+
* @param {string | undefined} body
|
|
5738
|
+
* @returns {boolean}
|
|
6167
5739
|
*/
|
|
6168
|
-
function
|
|
6169
|
-
if (!
|
|
6170
|
-
|
|
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
|
-
*
|
|
6180
|
-
*
|
|
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
|
-
*
|
|
6206
|
-
*
|
|
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
|
-
*
|
|
6243
|
-
*
|
|
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
|
-
*
|
|
6291
|
-
*
|
|
6292
|
-
*
|
|
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 {
|
|
6295
|
-
* @returns {
|
|
5772
|
+
* @param {any} parsed Parsed request body
|
|
5773
|
+
* @returns {"main"|"title"|"small"|"empty"|"unknown"}
|
|
6296
5774
|
*/
|
|
6297
|
-
function
|
|
6298
|
-
if (!
|
|
6299
|
-
|
|
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
|
-
|
|
6441
|
-
|
|
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
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
6549
|
-
*
|
|
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
|
-
*
|
|
6569
|
-
*
|
|
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
|
-
*
|
|
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
|
|
6580
|
-
|
|
6581
|
-
const
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
6669
|
-
* @
|
|
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
|
|
6672
|
-
if (!
|
|
6673
|
-
if (
|
|
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
|
|
6690
|
-
* @
|
|
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
|
-
* @
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
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
|
-
|
|
8662
|
-
|
|
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
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
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
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8753
|
-
|
|
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
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
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
|
|
6350
|
+
return getCachedCCPrompt();
|
|
9543
6351
|
},
|
|
9544
6352
|
resetCachedCCPrompt() {
|
|
9545
|
-
|
|
6353
|
+
resetCachedCCPrompt();
|
|
9546
6354
|
},
|
|
9547
6355
|
SUBAGENT_CC_ANCHOR,
|
|
9548
6356
|
CLAUDE_CODE_IDENTITY_STRING,
|