opencode-anthropic-fix 0.1.32 → 0.2.1
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 +9 -6
- package/cli.mjs +43 -35
- package/index.mjs +377 -3646
- package/lib/backoff.mjs +1 -1
- package/lib/cc-credentials.mjs +1 -1
- package/lib/config.mjs +2 -2
- package/lib/config.test.mjs +305 -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 +528 -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 +682 -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/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/rotation.mjs +16 -0
- package/lib/rotation.test.mjs +46 -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
|
@@ -15,15 +15,11 @@ import {
|
|
|
15
15
|
FALLBACK_CLAUDE_CLI_VERSION,
|
|
16
16
|
CLAUDE_CODE_NPM_LATEST_URL,
|
|
17
17
|
CLAUDE_CODE_BUILD_TIME,
|
|
18
|
-
EXPERIMENTAL_BETA_FLAGS,
|
|
19
|
-
BETA_SHORTCUTS,
|
|
20
18
|
resolveBetaShortcut,
|
|
21
|
-
buildExtendedUserAgent,
|
|
22
19
|
} from "./lib/request-headers.mjs";
|
|
23
|
-
import { loadConfig, loadConfigFresh, saveConfig,
|
|
20
|
+
import { loadConfig, loadConfigFresh, saveConfig, getConfigDir } from "./lib/config.mjs";
|
|
24
21
|
import { loadContextHintDisabledFlag, saveContextHintDisabledFlag } from "./lib/context-hint-persist.mjs";
|
|
25
22
|
import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
|
|
26
|
-
import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
|
|
27
23
|
import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
|
|
28
24
|
import {
|
|
29
25
|
isAccountSpecificError,
|
|
@@ -37,6 +33,60 @@ import {
|
|
|
37
33
|
import { callHaiku } from "./lib/haiku-call.mjs";
|
|
38
34
|
import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
|
|
39
35
|
import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
|
|
36
|
+
import {
|
|
37
|
+
estimatePromptTokensFromParsed,
|
|
38
|
+
applySessionToolResultDedupe,
|
|
39
|
+
maybeApplySessionToolResultDedupe,
|
|
40
|
+
applyContextHintCompaction,
|
|
41
|
+
} from "./lib/token-economy/transforms.mjs";
|
|
42
|
+
import { shouldMicrocompact, buildMicrocompactBetas } from "./lib/token-economy/microcompact.mjs";
|
|
43
|
+
import { redactSecrets, redactString } from "./lib/redact.mjs";
|
|
44
|
+
import {
|
|
45
|
+
createTransformedSSEStream,
|
|
46
|
+
resolveStreamIdleTimeoutMs,
|
|
47
|
+
stripMcpPrefixFromParsedEvent,
|
|
48
|
+
} from "./lib/mimicry/response-stream.mjs";
|
|
49
|
+
import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
|
|
50
|
+
import { isTruthyEnv } from "./lib/env.mjs";
|
|
51
|
+
import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
|
|
52
|
+
import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
|
|
53
|
+
import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
|
|
54
|
+
import { SERVICE_WIDE_MAX_RETRIES, CONSECUTIVE_529_FALLBACK_THRESHOLD } from "./lib/tuning.mjs";
|
|
55
|
+
import {
|
|
56
|
+
computeServiceRetrySleepMs,
|
|
57
|
+
selectFallbackModel,
|
|
58
|
+
shouldServiceRetry,
|
|
59
|
+
isTransientRateLimit,
|
|
60
|
+
} from "./lib/retry/overload-loop.mjs";
|
|
61
|
+
import {
|
|
62
|
+
buildRequestHeaders,
|
|
63
|
+
buildAnthropicBetaHeader,
|
|
64
|
+
parseRequestBodyMetadata,
|
|
65
|
+
detectProvider,
|
|
66
|
+
extractFileIds,
|
|
67
|
+
} from "./lib/mimicry/headers.mjs";
|
|
68
|
+
import {
|
|
69
|
+
buildSystemPromptBlocks,
|
|
70
|
+
compactSystemText,
|
|
71
|
+
compactToolDescription,
|
|
72
|
+
dedupeSystemBlocks,
|
|
73
|
+
getCachedCCPrompt,
|
|
74
|
+
isTitleGeneratorSystemBlocks,
|
|
75
|
+
normalizeSystemTextBlocks,
|
|
76
|
+
resetCachedCCPrompt,
|
|
77
|
+
sanitizeSystemText,
|
|
78
|
+
CLAUDE_CODE_IDENTITY_STRING,
|
|
79
|
+
SUBAGENT_CC_ANCHOR,
|
|
80
|
+
} from "./lib/mimicry/system-prompt.mjs";
|
|
81
|
+
import {
|
|
82
|
+
hasOneMillionContext,
|
|
83
|
+
isEligibleFor1MContext,
|
|
84
|
+
isOpus46Model,
|
|
85
|
+
isOpus47Model,
|
|
86
|
+
isOpus48Model,
|
|
87
|
+
} from "./lib/mimicry/models.mjs";
|
|
88
|
+
|
|
89
|
+
export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/models.mjs";
|
|
40
90
|
|
|
41
91
|
// Max times a single logical request may fall back from fast->standard speed on
|
|
42
92
|
// the same account before giving up the fast attempt entirely. 1 is enough: one
|
|
@@ -185,7 +235,7 @@ async function runHaikuSessionSummarize(
|
|
|
185
235
|
}
|
|
186
236
|
}
|
|
187
237
|
|
|
188
|
-
export async function AnthropicAuthPlugin({ client
|
|
238
|
+
export async function AnthropicAuthPlugin({ client }) {
|
|
189
239
|
const config = loadConfig();
|
|
190
240
|
_pluginConfig = config; // expose to module-level functions (cache stats, response headers)
|
|
191
241
|
// QA fix H6: read emulation settings live from config instead of stale const capture
|
|
@@ -1135,7 +1185,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1135
1185
|
config.toasts.quiet = enabled;
|
|
1136
1186
|
},
|
|
1137
1187
|
strategy: () => {
|
|
1138
|
-
const valid = ["sticky", "round-robin", "hybrid"];
|
|
1188
|
+
const valid = ["sticky", "round-robin", "hybrid", "single"];
|
|
1139
1189
|
if (valid.includes(value)) {
|
|
1140
1190
|
saveConfig({ account_selection_strategy: value });
|
|
1141
1191
|
strategyState.manualOverride = true;
|
|
@@ -2658,7 +2708,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2658
2708
|
const maxServiceRetries =
|
|
2659
2709
|
requestClass === "background"
|
|
2660
2710
|
? (config.request_classification?.background_max_service_retries ?? 0)
|
|
2661
|
-
:
|
|
2711
|
+
: SERVICE_WIDE_MAX_RETRIES;
|
|
2662
2712
|
const maxShouldRetries =
|
|
2663
2713
|
requestClass === "background" ? (config.request_classification?.background_max_should_retries ?? 1) : 3;
|
|
2664
2714
|
let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
|
|
@@ -3095,12 +3145,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3095
3145
|
// mutates system[0] each turn, invalidating the prompt cache.
|
|
3096
3146
|
const finalBody = body;
|
|
3097
3147
|
|
|
3148
|
+
const correlationId = createDebugCorrelationId();
|
|
3149
|
+
|
|
3098
3150
|
// Opt-in: dump the OUTGOING body (post-cch) so diagnostics reflect
|
|
3099
3151
|
// exactly what went on the wire. Previously dumped `body` which
|
|
3100
3152
|
// still had the cch=00000 placeholder — that confused debugging.
|
|
3101
3153
|
// Rotates at 10 files to cap disk usage. Files live under
|
|
3102
3154
|
// ~/.opencode/opencode-anthropic-fix/request-dumps/.
|
|
3103
|
-
if (config
|
|
3155
|
+
if (isDebugSinkEnabled(config, "body") && typeof finalBody === "string") {
|
|
3104
3156
|
try {
|
|
3105
3157
|
const fs = await import("node:fs");
|
|
3106
3158
|
const path = await import("node:path");
|
|
@@ -3116,7 +3168,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3116
3168
|
fs.unlinkSync(path.join(dir, existing.shift()));
|
|
3117
3169
|
}
|
|
3118
3170
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3119
|
-
|
|
3171
|
+
const dump = createDebugRequestDump(correlationId, ts, finalBody);
|
|
3172
|
+
fs.writeFileSync(path.join(dir, dump.filename), dump.content);
|
|
3120
3173
|
} catch {
|
|
3121
3174
|
// Disk full, permissions, whatever — never block the request.
|
|
3122
3175
|
}
|
|
@@ -3125,6 +3178,23 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3125
3178
|
// Execute the request
|
|
3126
3179
|
let response;
|
|
3127
3180
|
try {
|
|
3181
|
+
if (isDebugSinkEnabled(config, "headers")) {
|
|
3182
|
+
try {
|
|
3183
|
+
const { existsSync, renameSync, statSync, unlinkSync, writeFileSync } = await import("node:fs");
|
|
3184
|
+
const { join } = await import("node:path");
|
|
3185
|
+
const debugFile = join(getConfigDir(), "debug-headers.log");
|
|
3186
|
+
const rotatedFile = `${debugFile}.1`;
|
|
3187
|
+
if (existsSync(debugFile) && statSync(debugFile).size > 2 * 1024 * 1024) {
|
|
3188
|
+
if (existsSync(rotatedFile)) unlinkSync(rotatedFile);
|
|
3189
|
+
renameSync(debugFile, rotatedFile);
|
|
3190
|
+
}
|
|
3191
|
+
const ts = new Date().toISOString();
|
|
3192
|
+
const entry = createDebugOutgoingHeadersEntry(correlationId, ts, requestHeaders);
|
|
3193
|
+
writeFileSync(debugFile, entry, { flag: "a" });
|
|
3194
|
+
} catch (e) {
|
|
3195
|
+
debugLog("failed to write outgoing request headers to debug-headers.log", e);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3128
3198
|
response = await fetch(requestInput, {
|
|
3129
3199
|
...requestInit,
|
|
3130
3200
|
body: finalBody,
|
|
@@ -3183,7 +3253,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3183
3253
|
|
|
3184
3254
|
// Debug: log all response headers to file for diagnosis
|
|
3185
3255
|
// Placed BEFORE the response.ok guard so we capture headers on ALL responses
|
|
3186
|
-
if (config
|
|
3256
|
+
if (isDebugSinkEnabled(config, "headers")) {
|
|
3187
3257
|
const rlHeaders = {};
|
|
3188
3258
|
const allHeaders = {};
|
|
3189
3259
|
response.headers.forEach((value, key) => {
|
|
@@ -3192,6 +3262,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3192
3262
|
rlHeaders[key] = value;
|
|
3193
3263
|
}
|
|
3194
3264
|
});
|
|
3265
|
+
const redactedRlHeaders = redactSecrets(rlHeaders);
|
|
3266
|
+
const redactedAllHeaders = redactSecrets(allHeaders);
|
|
3195
3267
|
debugLog(
|
|
3196
3268
|
"response status:",
|
|
3197
3269
|
response.status,
|
|
@@ -3202,19 +3274,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3202
3274
|
"accountManager:",
|
|
3203
3275
|
!!accountManager,
|
|
3204
3276
|
);
|
|
3205
|
-
debugLog("ALL response headers:",
|
|
3277
|
+
debugLog("ALL response headers:", redactedAllHeaders);
|
|
3206
3278
|
// Write to file for reliable access
|
|
3207
3279
|
try {
|
|
3208
|
-
const { writeFileSync } = await import("node:fs");
|
|
3280
|
+
const { existsSync, renameSync, statSync, unlinkSync, writeFileSync } = await import("node:fs");
|
|
3209
3281
|
const { join } = await import("node:path");
|
|
3210
3282
|
const debugFile = join(getConfigDir(), "debug-headers.log");
|
|
3283
|
+
const rotatedFile = `${debugFile}.1`;
|
|
3284
|
+
if (existsSync(debugFile) && statSync(debugFile).size > 2 * 1024 * 1024) {
|
|
3285
|
+
if (existsSync(rotatedFile)) unlinkSync(rotatedFile);
|
|
3286
|
+
renameSync(debugFile, rotatedFile);
|
|
3287
|
+
}
|
|
3211
3288
|
const ts = new Date().toISOString();
|
|
3212
|
-
const entry =
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3289
|
+
const entry = createDebugResponseHeadersEntry(correlationId, ts, response, {
|
|
3290
|
+
account: !!account,
|
|
3291
|
+
accountManager: !!accountManager,
|
|
3292
|
+
rateLimitHeaders: redactedRlHeaders,
|
|
3293
|
+
allHeaders: redactedAllHeaders,
|
|
3294
|
+
});
|
|
3218
3295
|
writeFileSync(debugFile, entry, { flag: "a" });
|
|
3219
3296
|
} catch (e) {
|
|
3220
3297
|
debugLog("failed to write debug-headers.log", e);
|
|
@@ -3722,7 +3799,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3722
3799
|
status: response.status,
|
|
3723
3800
|
errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
|
|
3724
3801
|
});
|
|
3725
|
-
return transformResponse(response);
|
|
3802
|
+
return transformResponse(response, undefined, undefined, correlationId);
|
|
3726
3803
|
}
|
|
3727
3804
|
|
|
3728
3805
|
const accountSpecific = isAccountSpecificError(response.status, errorBody);
|
|
@@ -3754,13 +3831,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3754
3831
|
// Transient 429: short retry-after (<=10s) is a burst throttle.
|
|
3755
3832
|
// Retry on the SAME account instead of rotating — avoids wasting
|
|
3756
3833
|
// 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
|
-
) {
|
|
3834
|
+
if (isTransientRateLimit(response.status, reason, retryAfterMs, TRANSIENT_RETRY_THRESHOLD_MS)) {
|
|
3764
3835
|
debugLog("transient 429: sleeping before same-account retry", {
|
|
3765
3836
|
retryAfterMs,
|
|
3766
3837
|
account: account.email || `Account ${account.index + 1}`,
|
|
@@ -3851,26 +3922,19 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3851
3922
|
|
|
3852
3923
|
// 529 (overloaded) and 503 (service unavailable) — brief sleep-and-retry
|
|
3853
3924
|
// 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
|
-
) {
|
|
3925
|
+
if (shouldServiceRetry(response.status, serviceWideRetryCount, maxServiceRetries)) {
|
|
3858
3926
|
serviceWideRetryCount++;
|
|
3859
3927
|
|
|
3860
3928
|
// Track consecutive 529s for model fallback
|
|
3861
3929
|
if (response.status === 529) {
|
|
3862
3930
|
consecutive529Count++;
|
|
3863
|
-
if (consecutive529Count >=
|
|
3931
|
+
if (consecutive529Count >= CONSECUTIVE_529_FALLBACK_THRESHOLD && requestInit.body) {
|
|
3864
3932
|
try {
|
|
3865
3933
|
// QA fix: parse from requestInit.body (pre-transform) to avoid
|
|
3866
3934
|
// double-transformation (mcp_ prefix, system blocks, metadata).
|
|
3867
3935
|
const parsedForFallback = JSON.parse(requestInit.body);
|
|
3868
3936
|
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");
|
|
3937
|
+
const fallbackModel = selectFallbackModel(currentModel);
|
|
3874
3938
|
|
|
3875
3939
|
if (fallbackModel) {
|
|
3876
3940
|
parsedForFallback.model = fallbackModel;
|
|
@@ -3895,9 +3959,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3895
3959
|
consecutive529Count = 0;
|
|
3896
3960
|
}
|
|
3897
3961
|
|
|
3898
|
-
const
|
|
3899
|
-
const jitter = 1 - Math.random() * 0.25;
|
|
3900
|
-
const sleepMs = Math.round(baseDelay * jitter * 1000);
|
|
3962
|
+
const sleepMs = computeServiceRetrySleepMs(serviceWideRetryCount);
|
|
3901
3963
|
const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
|
|
3902
3964
|
debugLog(`service-wide ${retryLabel} error, sleeping before retry`, {
|
|
3903
3965
|
status: response.status,
|
|
@@ -3963,7 +4025,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3963
4025
|
status: response.status,
|
|
3964
4026
|
});
|
|
3965
4027
|
}
|
|
3966
|
-
return transformResponse(response);
|
|
4028
|
+
return transformResponse(response, undefined, undefined, correlationId);
|
|
3967
4029
|
}
|
|
3968
4030
|
|
|
3969
4031
|
// Success
|
|
@@ -4149,7 +4211,17 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
4149
4211
|
}
|
|
4150
4212
|
: null;
|
|
4151
4213
|
|
|
4152
|
-
return transformResponse(
|
|
4214
|
+
return transformResponse(
|
|
4215
|
+
response,
|
|
4216
|
+
usageCallback
|
|
4217
|
+
? (stats) => {
|
|
4218
|
+
usageCallback(stats);
|
|
4219
|
+
if (stats.lastStopReason) sessionMetrics.lastStopReason = stats.lastStopReason;
|
|
4220
|
+
}
|
|
4221
|
+
: null,
|
|
4222
|
+
accountErrorCallback,
|
|
4223
|
+
correlationId,
|
|
4224
|
+
);
|
|
4153
4225
|
}
|
|
4154
4226
|
|
|
4155
4227
|
// All accounts tried
|
|
@@ -4375,63 +4447,63 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
4375
4447
|
// Session-level cache & cost tracking (Phase 4)
|
|
4376
4448
|
// ---------------------------------------------------------------------------
|
|
4377
4449
|
|
|
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
4450
|
/** Module-level config ref for functions outside AnthropicAuthPlugin closure. */
|
|
4380
4451
|
let _pluginConfig = null;
|
|
4381
4452
|
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4453
|
+
const _debugSessionId = randomUUID().slice(0, 8);
|
|
4454
|
+
let _debugReqSeq = 0;
|
|
4455
|
+
|
|
4456
|
+
export function createDebugCorrelationId() {
|
|
4457
|
+
return `${_debugSessionId}-${(++_debugReqSeq).toString(36).padStart(4, "0")}`;
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
export function isDebugSinkEnabled(config, sink) {
|
|
4461
|
+
return sink === "body" ? config.token_economy?.debug_dump_bodies === true : Boolean(config.debug);
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
export function createDebugRequestDump(correlationId, timestamp, finalBody) {
|
|
4389
4465
|
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(),
|
|
4466
|
+
filename: `req-${timestamp}-${correlationId}.json`,
|
|
4467
|
+
content: JSON.stringify({ correlationId, timestamp, bodyRedacted: redactString(finalBody) }),
|
|
4431
4468
|
};
|
|
4432
4469
|
}
|
|
4433
4470
|
|
|
4434
|
-
|
|
4471
|
+
async function writeSseCapture(correlationId, buf, truncated) {
|
|
4472
|
+
const fs = await import("node:fs");
|
|
4473
|
+
const path = await import("node:path");
|
|
4474
|
+
const os = await import("node:os");
|
|
4475
|
+
const dir = path.join(os.homedir(), ".opencode", "opencode-anthropic-fix", "request-dumps");
|
|
4476
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4477
|
+
const existing = fs
|
|
4478
|
+
.readdirSync(dir)
|
|
4479
|
+
.filter((f) => f.startsWith("res-") && f.endsWith(".sse"))
|
|
4480
|
+
.sort();
|
|
4481
|
+
while (existing.length >= 10) {
|
|
4482
|
+
fs.unlinkSync(path.join(dir, existing.shift()));
|
|
4483
|
+
}
|
|
4484
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
4485
|
+
fs.writeFileSync(
|
|
4486
|
+
path.join(dir, `res-${ts}-${correlationId}.sse`),
|
|
4487
|
+
redactString(buf) + (truncated ? "\n[capture truncated at 256KB]" : ""),
|
|
4488
|
+
);
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
export function createDebugOutgoingHeadersEntry(correlationId, timestamp, requestHeaders) {
|
|
4492
|
+
return [
|
|
4493
|
+
`\n=== ${timestamp} | corr=${correlationId} | OUTGOING request headers ===`,
|
|
4494
|
+
JSON.stringify(redactSecrets(requestHeaders), null, 2),
|
|
4495
|
+
"",
|
|
4496
|
+
].join("\n");
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
export function createDebugResponseHeadersEntry(correlationId, timestamp, response, debugHeaders) {
|
|
4500
|
+
return [
|
|
4501
|
+
`\n=== ${timestamp} | corr=${correlationId} | status=${response.status} ok=${response.ok} account=${debugHeaders.account} mgr=${debugHeaders.accountManager} ===`,
|
|
4502
|
+
`Rate-limit headers: ${JSON.stringify(debugHeaders.rateLimitHeaders, null, 2)}`,
|
|
4503
|
+
`All headers: ${JSON.stringify(debugHeaders.allHeaders, null, 2)}`,
|
|
4504
|
+
"",
|
|
4505
|
+
].join("\n");
|
|
4506
|
+
}
|
|
4435
4507
|
|
|
4436
4508
|
// ---------------------------------------------------------------------------
|
|
4437
4509
|
// Adaptive 1M context state
|
|
@@ -4482,29 +4554,6 @@ const cacheBreakState = {
|
|
|
4482
4554
|
boundaryStability: new Map(),
|
|
4483
4555
|
};
|
|
4484
4556
|
|
|
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
4557
|
// ---------------------------------------------------------------------------
|
|
4509
4558
|
// Microcompact state (Phase 3, Task 3.4)
|
|
4510
4559
|
// ---------------------------------------------------------------------------
|
|
@@ -4518,31 +4567,6 @@ const microcompactState = {
|
|
|
4518
4567
|
lastActivatedTurn: 0,
|
|
4519
4568
|
};
|
|
4520
4569
|
|
|
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
4570
|
/**
|
|
4547
4571
|
* Hash a string for cache source fingerprinting.
|
|
4548
4572
|
* @param {string} content
|
|
@@ -4734,41 +4758,6 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
|
|
|
4734
4758
|
* @param {string} bodyStr - JSON request body
|
|
4735
4759
|
* @returns {Map<string, string>} source_id → hash
|
|
4736
4760
|
*/
|
|
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
4761
|
/**
|
|
4773
4762
|
* Read a single incoming request header by name (case-insensitive) from either a
|
|
4774
4763
|
* `Request` input or a fetch `init.headers` (Headers | array | plain object).
|
|
@@ -4809,41 +4798,6 @@ function getIncomingHeader(input, requestInit, name) {
|
|
|
4809
4798
|
return null;
|
|
4810
4799
|
}
|
|
4811
4800
|
|
|
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
4801
|
function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
|
|
4848
4802
|
const hashes = new Map();
|
|
4849
4803
|
try {
|
|
@@ -5020,88 +4974,6 @@ function classifyApiRequest(body) {
|
|
|
5020
4974
|
}
|
|
5021
4975
|
}
|
|
5022
4976
|
|
|
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
4977
|
/**
|
|
5106
4978
|
* Detect diminishing returns: ≥3 continuations AND last 3 output deltas all < 500 tokens.
|
|
5107
4979
|
*
|
|
@@ -5131,54 +5003,6 @@ function estimatePromptTokens(bodyString) {
|
|
|
5131
5003
|
}
|
|
5132
5004
|
}
|
|
5133
5005
|
|
|
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
5006
|
/**
|
|
5183
5007
|
* Analyze a request body to produce a token breakdown by role and tool.
|
|
5184
5008
|
* Used by `/anthropic context` command.
|
|
@@ -5499,16 +5323,6 @@ function updateSessionMetrics(usage, model) {
|
|
|
5499
5323
|
}
|
|
5500
5324
|
}
|
|
5501
5325
|
|
|
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
5326
|
/**
|
|
5513
5327
|
* Write cache transparency stats to a well-known JSON file for TUI consumption.
|
|
5514
5328
|
* The OpenCode TUI watches this file to display cache metrics in the status bar.
|
|
@@ -5752,14 +5566,10 @@ process.once("beforeExit", _beforeExitHandler);
|
|
|
5752
5566
|
// Request building helpers (extracted from original fetch interceptor)
|
|
5753
5567
|
// ---------------------------------------------------------------------------
|
|
5754
5568
|
|
|
5755
|
-
const BILLING_HASH_SALT = "59cf53e54c78";
|
|
5756
|
-
const BILLING_HASH_INDICES = [4, 7, 20];
|
|
5757
|
-
|
|
5758
5569
|
// cch attestation: RE-ENABLED with xxHash64 (matching Bun binary's Attestation.zig).
|
|
5759
5570
|
// The compiled Bun binary computes cch dynamically: xxHash64(body, seed) & 0xFFFFF.
|
|
5760
5571
|
// Captured real CC v2.1.107 request shows cch=6d00f (5-hex-char, 20-bit masked hash).
|
|
5761
5572
|
// Seed extracted from binary: 0x6E52736AC806831E (unchanged since v2.1.96).
|
|
5762
|
-
const CCH_SEED = 0x6e52736ac806831en; // BigInt — Attestation.zig seed
|
|
5763
5573
|
|
|
5764
5574
|
/** @type {null | ((buf: Uint8Array, seed: bigint) => bigint)} */
|
|
5765
5575
|
let _xxh64Raw = null;
|
|
@@ -5767,35 +5577,6 @@ const _xxhashReady = xxhashInit().then((h) => {
|
|
|
5767
5577
|
_xxh64Raw = h.h64Raw;
|
|
5768
5578
|
});
|
|
5769
5579
|
|
|
5770
|
-
/**
|
|
5771
|
-
* Compute and replace the cch=00000 placeholder in the serialized body with
|
|
5772
|
-
* xxHash64(body, seed) & 0xFFFFF, matching the Bun binary's native attestation.
|
|
5773
|
-
* @param {string} body - Serialized JSON body
|
|
5774
|
-
* @returns {Promise<string>} Body with cch replaced
|
|
5775
|
-
*/
|
|
5776
|
-
async function computeAndReplaceCCH(body) {
|
|
5777
|
-
if (typeof body !== "string" || !body.includes("cch=00000")) return body;
|
|
5778
|
-
await _xxhashReady;
|
|
5779
|
-
if (!_xxh64Raw) return body; // fallback: send as-is if wasm failed to load
|
|
5780
|
-
const bodyBytes = Buffer.from(body, "utf-8");
|
|
5781
|
-
const hash = _xxh64Raw(bodyBytes, CCH_SEED);
|
|
5782
|
-
const cch = (hash & 0xfffffn).toString(16).padStart(5, "0");
|
|
5783
|
-
return body.replace("cch=00000", `cch=${cch}`);
|
|
5784
|
-
}
|
|
5785
|
-
|
|
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
5580
|
/**
|
|
5800
5581
|
* Extract the text content of the first user message for billing hash computation.
|
|
5801
5582
|
* @param {any[] | undefined} messages
|
|
@@ -5839,2921 +5620,275 @@ function computeBillingCacheHash(firstUserMessage, version) {
|
|
|
5839
5620
|
* @param {Array} messages — The messages array from the parsed request body
|
|
5840
5621
|
* @returns {Array} — Repaired messages array
|
|
5841
5622
|
*/
|
|
5623
|
+
|
|
5624
|
+
const _ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
|
|
5625
|
+
// OpenCode SDK betas that leak through the host's Anthropic SDK but are NOT
|
|
5626
|
+
// part of CC's beta vocabulary. Filtered out when signature emulation is on.
|
|
5627
|
+
const USER_ID_STORAGE_FILE = "anthropic-signature-user-id";
|
|
5628
|
+
const DEBUG_SYSTEM_PROMPT_ENV = "OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT";
|
|
5842
5629
|
/**
|
|
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 } }}
|
|
5630
|
+
* Returns the persistent device ID (64-char hex string).
|
|
5631
|
+
* Migrates legacy UUID-format values to the new 64-hex format automatically.
|
|
5632
|
+
* @returns {string}
|
|
5854
5633
|
*/
|
|
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
|
-
}
|
|
5634
|
+
function getOrCreateDeviceId() {
|
|
5635
|
+
const configDir = getConfigDir();
|
|
5636
|
+
const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
|
|
5637
|
+
|
|
5638
|
+
try {
|
|
5639
|
+
if (existsSync(userIdPath)) {
|
|
5640
|
+
const existing = readFileSync(userIdPath, "utf-8").trim();
|
|
5641
|
+
if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
|
|
5871
5642
|
}
|
|
5643
|
+
} catch {
|
|
5644
|
+
// fall through and generate a new id
|
|
5872
5645
|
}
|
|
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
5646
|
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
}
|
|
5647
|
+
const generated = randomBytes(32).toString("hex");
|
|
5648
|
+
try {
|
|
5649
|
+
mkdirSync(configDir, { recursive: true });
|
|
5650
|
+
writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
5651
|
+
} catch {
|
|
5652
|
+
// Ignore filesystem errors; caller still gets generated ID for this runtime.
|
|
5653
|
+
}
|
|
5654
|
+
return generated;
|
|
5917
5655
|
}
|
|
5918
5656
|
|
|
5919
5657
|
/**
|
|
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.
|
|
5658
|
+
* @returns {boolean}
|
|
5924
5659
|
*/
|
|
5925
|
-
|
|
5660
|
+
function shouldDebugSystemPrompt() {
|
|
5661
|
+
return isTruthyEnv(process.env[DEBUG_SYSTEM_PROMPT_ENV]);
|
|
5662
|
+
}
|
|
5926
5663
|
|
|
5927
5664
|
/**
|
|
5928
|
-
*
|
|
5929
|
-
*
|
|
5665
|
+
* @param {string | undefined} value
|
|
5666
|
+
* @returns {boolean}
|
|
5930
5667
|
*/
|
|
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
5668
|
/**
|
|
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.
|
|
5669
|
+
* @param {string | undefined} value
|
|
5670
|
+
* @returns {boolean}
|
|
5944
5671
|
*/
|
|
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
5672
|
/**
|
|
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 } }}
|
|
5673
|
+
* @returns {boolean}
|
|
5979
5674
|
*/
|
|
5980
|
-
function applySessionToolResultDedupe(messages) {
|
|
5981
|
-
if (!Array.isArray(messages) || messages.length === 0) {
|
|
5982
|
-
return { messages, changed: false, stats: { deduped: 0 } };
|
|
5983
|
-
}
|
|
5984
5675
|
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
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
|
-
}
|
|
5676
|
+
/**
|
|
5677
|
+
* @returns {Record<string, string>}
|
|
5678
|
+
*/
|
|
6025
5679
|
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
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
|
-
}
|
|
5680
|
+
/**
|
|
5681
|
+
* @param {string | undefined} model
|
|
5682
|
+
* @returns {string | undefined}
|
|
5683
|
+
*/
|
|
5684
|
+
function logTransformedSystemPrompt(body) {
|
|
5685
|
+
if (!shouldDebugSystemPrompt()) return;
|
|
5686
|
+
if (!body || typeof body !== "string") return;
|
|
6048
5687
|
|
|
6049
|
-
|
|
6050
|
-
|
|
5688
|
+
try {
|
|
5689
|
+
const parsed = JSON.parse(body);
|
|
5690
|
+
if (!Object.prototype.hasOwnProperty.call(parsed, "system")) return;
|
|
5691
|
+
if (isTitleGeneratorSystemBlocks(normalizeSystemTextBlocks(parsed.system))) return;
|
|
5692
|
+
console.error(
|
|
5693
|
+
"[opencode-anthropic-auth][system-debug] transformed system:",
|
|
5694
|
+
JSON.stringify(parsed.system, null, 2),
|
|
5695
|
+
);
|
|
5696
|
+
} catch {
|
|
5697
|
+
// Ignore parse errors in debug logging path.
|
|
6051
5698
|
}
|
|
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
|
-
|
|
6067
|
-
return { messages: out, changed: true, stats: { deduped } };
|
|
6068
5699
|
}
|
|
6069
5700
|
|
|
6070
5701
|
/**
|
|
6071
|
-
*
|
|
6072
|
-
*
|
|
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}
|
|
5702
|
+
* @param {string | undefined} body
|
|
5703
|
+
* @returns {boolean}
|
|
6079
5704
|
*/
|
|
6080
|
-
function maybeApplySessionToolResultDedupe(messages, config) {
|
|
6081
|
-
const flag = config?.token_economy_strategies?.tool_result_dedupe_session_wide;
|
|
6082
|
-
if (flag !== true) return messages;
|
|
6083
|
-
const result = applySessionToolResultDedupe(messages);
|
|
6084
|
-
return result.messages;
|
|
6085
|
-
}
|
|
6086
5705
|
|
|
6087
5706
|
/**
|
|
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 }}
|
|
5707
|
+
* @param {string | undefined} body
|
|
5708
|
+
* @returns {boolean}
|
|
6099
5709
|
*/
|
|
6100
|
-
function applyTtlThinkingStrip(messages, ctx) {
|
|
6101
|
-
const now = ctx.now ?? Date.now();
|
|
6102
|
-
if (!Array.isArray(messages) || messages.length === 0) {
|
|
6103
|
-
return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
|
|
6104
|
-
}
|
|
6105
|
-
if (ctx.lastClearMs > 0 && now - ctx.lastClearMs < ctx.ttlMs) {
|
|
6106
|
-
return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
|
|
6107
|
-
}
|
|
6108
|
-
|
|
6109
|
-
// Find the last assistant message index — preserve its thinking.
|
|
6110
|
-
let lastAsstIdx = -1;
|
|
6111
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
6112
|
-
if (messages[i]?.role === "assistant") {
|
|
6113
|
-
lastAsstIdx = i;
|
|
6114
|
-
break;
|
|
6115
|
-
}
|
|
6116
|
-
}
|
|
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
|
-
}
|
|
6135
5710
|
|
|
6136
5711
|
/**
|
|
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 }}
|
|
5712
|
+
* @param {string | undefined} body
|
|
5713
|
+
* @returns {boolean}
|
|
6146
5714
|
*/
|
|
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
5715
|
|
|
6161
5716
|
/**
|
|
6162
|
-
*
|
|
6163
|
-
*
|
|
6164
|
-
*
|
|
6165
|
-
* @param {any[]} tools
|
|
6166
|
-
* @returns {any[]}
|
|
5717
|
+
* @param {URL | null} requestUrl
|
|
5718
|
+
* @returns {"anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle"}
|
|
6167
5719
|
*/
|
|
6168
|
-
function applyStableToolOrdering(tools) {
|
|
6169
|
-
if (!Array.isArray(tools) || tools.length < 2) return tools;
|
|
6170
|
-
// Preserve a pinned "first" for tools whose position is load-bearing (none today).
|
|
6171
|
-
return [...tools].sort((a, b) => {
|
|
6172
|
-
const an = typeof a?.name === "string" ? a.name : "";
|
|
6173
|
-
const bn = typeof b?.name === "string" ? b.name : "";
|
|
6174
|
-
return an < bn ? -1 : an > bn ? 1 : 0;
|
|
6175
|
-
});
|
|
6176
|
-
}
|
|
6177
5720
|
|
|
6178
5721
|
/**
|
|
6179
|
-
*
|
|
6180
|
-
*
|
|
5722
|
+
* Classify a request by inferred role, mirroring CC's `querySource` gate.
|
|
5723
|
+
* CC gates features like context-hint on `querySource.startsWith("repl_main_thread")`.
|
|
5724
|
+
* We don't have that string on the wire, so we infer from body shape.
|
|
6181
5725
|
*
|
|
6182
|
-
*
|
|
6183
|
-
*
|
|
6184
|
-
*
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
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
|
-
|
|
6204
|
-
/**
|
|
6205
|
-
* Adaptive thinking — zero the thinking budget for trivially simple follow-ups.
|
|
6206
|
-
* "Simple" heuristic: most recent user message is short (<200 chars), no file
|
|
6207
|
-
* references, and the conversation is past turn 1 (so we have context).
|
|
5726
|
+
* Returns one of:
|
|
5727
|
+
* - "main" → interactive main thread (long system, normal max_tokens, messages present)
|
|
5728
|
+
* - "title" → title / name generation (tiny max_tokens, 1 message)
|
|
5729
|
+
* - "small" → short background query (small max_tokens but not title)
|
|
5730
|
+
* - "empty" → pre-warm / no messages
|
|
5731
|
+
* - "unknown" → treat as main for safety
|
|
6208
5732
|
*
|
|
6209
|
-
* @param {any} parsed Parsed request body
|
|
6210
|
-
* @returns {
|
|
5733
|
+
* @param {any} parsed Parsed request body
|
|
5734
|
+
* @returns {"main"|"title"|"small"|"empty"|"unknown"}
|
|
6211
5735
|
*/
|
|
6212
|
-
function
|
|
6213
|
-
if (!parsed ||
|
|
6214
|
-
return { applied: false, previousBudget: null };
|
|
6215
|
-
}
|
|
5736
|
+
function classifyRequestRole(parsed) {
|
|
5737
|
+
if (!parsed || typeof parsed !== "object") return "unknown";
|
|
6216
5738
|
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
|
6217
|
-
|
|
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
|
-
|
|
6241
|
-
/**
|
|
6242
|
-
* Cross-turn tool_result dedupe — when the same (tool name, input) pair
|
|
6243
|
-
* appeared earlier in the conversation, replace the later result content
|
|
6244
|
-
* with a pointer string. Safe-set only: Read, Grep, Glob, LS.
|
|
6245
|
-
*
|
|
6246
|
-
* @param {Array} messages
|
|
6247
|
-
* @param {{ seen: Map<string, string>, safeTools: Set<string> }} ctx
|
|
6248
|
-
* @returns {{ messages: Array, changed: boolean, deduped: number }}
|
|
6249
|
-
*/
|
|
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
|
-
|
|
6289
|
-
/**
|
|
6290
|
-
* Trailing-summary trimmer — strip the final text block of past assistant
|
|
6291
|
-
* messages if it looks like a summary (ends with "...done" / "summary" /
|
|
6292
|
-
* numbered list / "I've X'd Y"). Only applies to messages past the last one.
|
|
6293
|
-
*
|
|
6294
|
-
* @param {Array} messages
|
|
6295
|
-
* @returns {{ messages: Array, changed: boolean, trimmed: number }}
|
|
6296
|
-
*/
|
|
6297
|
-
function applyTrailingSummaryTrim(messages) {
|
|
6298
|
-
if (!Array.isArray(messages) || messages.length < 2) {
|
|
6299
|
-
return { messages, changed: false, trimmed: 0 };
|
|
6300
|
-
}
|
|
6301
|
-
const SUMMARY_PATTERNS = [
|
|
6302
|
-
/\b(summary|summar(y|ised|ized)):/i,
|
|
6303
|
-
/\bto summari[sz]e\b/i,
|
|
6304
|
-
/^\s*in (summary|short|brief)/im,
|
|
6305
|
-
/\bi['']ve (done|completed|implemented|added|updated|fixed) /i,
|
|
6306
|
-
/\bthat's it\b/i,
|
|
6307
|
-
];
|
|
6308
|
-
|
|
6309
|
-
let lastAsstIdx = -1;
|
|
6310
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
6311
|
-
if (messages[i]?.role === "assistant") {
|
|
6312
|
-
lastAsstIdx = i;
|
|
6313
|
-
break;
|
|
6314
|
-
}
|
|
6315
|
-
}
|
|
6316
|
-
|
|
6317
|
-
let trimmed = 0;
|
|
6318
|
-
const out = messages.map((msg, i) => {
|
|
6319
|
-
if (msg.role !== "assistant" || i === lastAsstIdx) return msg;
|
|
6320
|
-
if (!Array.isArray(msg.content)) return msg;
|
|
6321
|
-
|
|
6322
|
-
const last = msg.content[msg.content.length - 1];
|
|
6323
|
-
if (!last || last.type !== "text" || typeof last.text !== "string") return msg;
|
|
6324
|
-
const text = last.text;
|
|
6325
|
-
if (text.length < 80) return msg;
|
|
6326
|
-
const isSummary = SUMMARY_PATTERNS.some((p) => p.test(text));
|
|
6327
|
-
if (!isSummary) return msg;
|
|
6328
|
-
|
|
6329
|
-
trimmed += 1;
|
|
6330
|
-
const newContent = msg.content.slice(0, -1);
|
|
6331
|
-
if (newContent.length === 0) return msg; // keep at least one block
|
|
6332
|
-
return { ...msg, content: newContent };
|
|
6333
|
-
});
|
|
6334
|
-
|
|
6335
|
-
return { messages: out, changed: trimmed > 0, trimmed };
|
|
6336
|
-
}
|
|
6337
|
-
|
|
6338
|
-
function repairOrphanedToolUseBlocks(messages) {
|
|
6339
|
-
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
6340
|
-
|
|
6341
|
-
const repaired = [];
|
|
6342
|
-
|
|
6343
|
-
for (let i = 0; i < messages.length; i++) {
|
|
6344
|
-
const msg = messages[i];
|
|
6345
|
-
repaired.push(msg);
|
|
6346
|
-
|
|
6347
|
-
// Only check assistant messages with array content
|
|
6348
|
-
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
6349
|
-
|
|
6350
|
-
// Collect tool_use IDs from this assistant message
|
|
6351
|
-
const toolUseIds = [];
|
|
6352
|
-
for (const block of msg.content) {
|
|
6353
|
-
if (block.type === "tool_use" && block.id) {
|
|
6354
|
-
toolUseIds.push(block.id);
|
|
6355
|
-
}
|
|
6356
|
-
}
|
|
6357
|
-
if (toolUseIds.length === 0) continue;
|
|
6358
|
-
|
|
6359
|
-
// Check if the next message is a user message with matching tool_results
|
|
6360
|
-
const next = messages[i + 1];
|
|
6361
|
-
if (next && next.role === "user" && Array.isArray(next.content)) {
|
|
6362
|
-
// Collect tool_result IDs present in the next user message
|
|
6363
|
-
const resultIds = new Set();
|
|
6364
|
-
for (const block of next.content) {
|
|
6365
|
-
if (block.type === "tool_result" && block.tool_use_id) {
|
|
6366
|
-
resultIds.add(block.tool_use_id);
|
|
6367
|
-
}
|
|
6368
|
-
}
|
|
6369
|
-
|
|
6370
|
-
// Find which tool_use IDs are missing from the tool_results
|
|
6371
|
-
const missingIds = toolUseIds.filter((id) => !resultIds.has(id));
|
|
6372
|
-
if (missingIds.length === 0) continue; // All paired, nothing to fix
|
|
6373
|
-
|
|
6374
|
-
// There are missing tool_results — inject them into the existing user message.
|
|
6375
|
-
// Clone the next message to avoid mutating the original.
|
|
6376
|
-
const patchedNext = {
|
|
6377
|
-
...next,
|
|
6378
|
-
content: [
|
|
6379
|
-
...missingIds.map((id) => ({
|
|
6380
|
-
type: "tool_result",
|
|
6381
|
-
tool_use_id: id,
|
|
6382
|
-
content: "[Result unavailable — tool execution was interrupted]",
|
|
6383
|
-
})),
|
|
6384
|
-
...next.content,
|
|
6385
|
-
],
|
|
6386
|
-
};
|
|
6387
|
-
// Replace the next message in-place by skipping it and pushing the patched version
|
|
6388
|
-
i++; // skip original next
|
|
6389
|
-
repaired.push(patchedNext);
|
|
6390
|
-
} else {
|
|
6391
|
-
// Next message is missing or is not a user message — synthesize a full
|
|
6392
|
-
// tool_result user message for all tool_use IDs.
|
|
6393
|
-
repaired.push({
|
|
6394
|
-
role: "user",
|
|
6395
|
-
content: toolUseIds.map((id) => ({
|
|
6396
|
-
type: "tool_result",
|
|
6397
|
-
tool_use_id: id,
|
|
6398
|
-
content: "[Result unavailable — tool execution was interrupted]",
|
|
6399
|
-
})),
|
|
6400
|
-
});
|
|
6401
|
-
}
|
|
6402
|
-
}
|
|
6403
|
-
|
|
6404
|
-
return repaired;
|
|
6405
|
-
}
|
|
6406
|
-
|
|
6407
|
-
function stripSlashCommandMessages(messages) {
|
|
6408
|
-
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
6409
|
-
|
|
6410
|
-
// Pattern: /anthropic followed by optional subcommand
|
|
6411
|
-
const CMD_RE = /^\s*\/anthropic\b/i;
|
|
6412
|
-
// Pattern: ▣ Anthropic — prefix used by all sendCommandMessage outputs
|
|
6413
|
-
const RESP_RE = /^▣\s*Anthropic/;
|
|
6414
|
-
|
|
6415
|
-
/**
|
|
6416
|
-
* Extract the first text content from a message's content field.
|
|
6417
|
-
* Handles both string content and array-of-blocks content.
|
|
6418
|
-
*/
|
|
6419
|
-
function getFirstText(msg) {
|
|
6420
|
-
if (typeof msg.content === "string") return msg.content;
|
|
6421
|
-
if (Array.isArray(msg.content)) {
|
|
6422
|
-
for (const block of msg.content) {
|
|
6423
|
-
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
6424
|
-
}
|
|
6425
|
-
}
|
|
6426
|
-
return "";
|
|
6427
|
-
}
|
|
6428
|
-
|
|
6429
|
-
/**
|
|
6430
|
-
* Check if a user message is a /anthropic command.
|
|
6431
|
-
* A message is a command if its text content starts with /anthropic.
|
|
6432
|
-
*/
|
|
6433
|
-
function isCommandMessage(msg) {
|
|
6434
|
-
if (msg.role !== "user") return false;
|
|
6435
|
-
const text = getFirstText(msg);
|
|
6436
|
-
return CMD_RE.test(text);
|
|
6437
|
-
}
|
|
5739
|
+
const maxTokens = typeof parsed.max_tokens === "number" ? parsed.max_tokens : null;
|
|
6438
5740
|
|
|
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);
|
|
5741
|
+
if (messages.length === 0) return "empty";
|
|
5742
|
+
if (maxTokens != null) {
|
|
5743
|
+
if (maxTokens <= 256 && messages.length <= 2) return "title";
|
|
5744
|
+
if (maxTokens <= 1024) return "small";
|
|
6447
5745
|
}
|
|
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;
|
|
5746
|
+
// System prompt length heuristic
|
|
5747
|
+
let sysLen = 0;
|
|
5748
|
+
if (typeof parsed.system === "string") {
|
|
5749
|
+
sysLen = parsed.system.length;
|
|
5750
|
+
} else if (Array.isArray(parsed.system)) {
|
|
5751
|
+
for (const s of parsed.system) {
|
|
5752
|
+
if (s && typeof s.text === "string") sysLen += s.text.length;
|
|
6466
5753
|
}
|
|
6467
|
-
|
|
6468
|
-
filtered.push(msg);
|
|
6469
5754
|
}
|
|
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 "";
|
|
5755
|
+
if (sysLen < 200 && messages.length <= 2 && (maxTokens == null || maxTokens <= 2048)) {
|
|
5756
|
+
return "small";
|
|
6489
5757
|
}
|
|
6490
|
-
return "";
|
|
5758
|
+
return "main";
|
|
6491
5759
|
}
|
|
6492
5760
|
|
|
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
5761
|
/**
|
|
6548
|
-
*
|
|
6549
|
-
*
|
|
6550
|
-
* Significantly reduces output token count by instructing the model to be concise.
|
|
6551
|
-
*/
|
|
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
|
-
|
|
6567
|
-
/**
|
|
6568
|
-
* Numeric length anchors text.
|
|
6569
|
-
* Extracted from CC v2.1.100. Hard word-count limits for output.
|
|
5762
|
+
* @param {any} body
|
|
5763
|
+
* @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
|
|
6570
5764
|
*/
|
|
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
5765
|
|
|
6574
5766
|
/**
|
|
6575
|
-
*
|
|
6576
|
-
*
|
|
5767
|
+
* @param {any[]} tools
|
|
5768
|
+
* @param {any[]} messages
|
|
6577
5769
|
* @returns {string}
|
|
6578
5770
|
*/
|
|
6579
|
-
function getOrCreateDeviceId() {
|
|
6580
|
-
const configDir = getConfigDir();
|
|
6581
|
-
const userIdPath = join(configDir, USER_ID_STORAGE_FILE);
|
|
6582
|
-
|
|
6583
|
-
try {
|
|
6584
|
-
if (existsSync(userIdPath)) {
|
|
6585
|
-
const existing = readFileSync(userIdPath, "utf-8").trim();
|
|
6586
|
-
if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
|
|
6587
|
-
}
|
|
6588
|
-
} catch {
|
|
6589
|
-
// fall through and generate a new id
|
|
6590
|
-
}
|
|
6591
|
-
|
|
6592
|
-
const generated = randomBytes(32).toString("hex");
|
|
6593
|
-
try {
|
|
6594
|
-
mkdirSync(configDir, { recursive: true });
|
|
6595
|
-
writeFileSync(userIdPath, `${generated}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
6596
|
-
} catch {
|
|
6597
|
-
// Ignore filesystem errors; caller still gets generated ID for this runtime.
|
|
6598
|
-
}
|
|
6599
|
-
return generated;
|
|
6600
|
-
}
|
|
6601
|
-
|
|
6602
|
-
/**
|
|
6603
|
-
* @returns {boolean}
|
|
6604
|
-
*/
|
|
6605
|
-
function shouldDebugSystemPrompt() {
|
|
6606
|
-
return isTruthyEnv(process.env[DEBUG_SYSTEM_PROMPT_ENV]);
|
|
6607
|
-
}
|
|
6608
|
-
|
|
6609
|
-
/**
|
|
6610
|
-
* @param {string | undefined} value
|
|
6611
|
-
* @returns {boolean}
|
|
6612
|
-
*/
|
|
6613
|
-
function isTruthyEnv(value) {
|
|
6614
|
-
if (!value) return false;
|
|
6615
|
-
const normalized = value.trim().toLowerCase();
|
|
6616
|
-
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
6617
|
-
}
|
|
6618
|
-
|
|
6619
|
-
/**
|
|
6620
|
-
* @param {string | undefined} value
|
|
6621
|
-
* @returns {boolean}
|
|
6622
|
-
*/
|
|
6623
|
-
function isFalsyEnv(value) {
|
|
6624
|
-
if (!value) return false;
|
|
6625
|
-
const normalized = value.trim().toLowerCase();
|
|
6626
|
-
return normalized === "0" || normalized === "false" || normalized === "no";
|
|
6627
|
-
}
|
|
6628
|
-
|
|
6629
|
-
/**
|
|
6630
|
-
* @returns {boolean}
|
|
6631
|
-
*/
|
|
6632
|
-
function isNonInteractiveMode() {
|
|
6633
|
-
if (isTruthyEnv(process.env.CI)) return true;
|
|
6634
|
-
return !process.stdout.isTTY;
|
|
6635
|
-
}
|
|
6636
|
-
|
|
6637
|
-
/**
|
|
6638
|
-
* @returns {Record<string, string>}
|
|
6639
|
-
*/
|
|
6640
|
-
function parseAnthropicCustomHeaders() {
|
|
6641
|
-
const raw = process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
6642
|
-
if (!raw) return {};
|
|
6643
|
-
|
|
6644
|
-
/** @type {Record<string, string>} */
|
|
6645
|
-
const headers = {};
|
|
6646
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
6647
|
-
const trimmed = line.trim();
|
|
6648
|
-
if (!trimmed) continue;
|
|
6649
|
-
const sep = trimmed.indexOf(":");
|
|
6650
|
-
if (sep <= 0) continue;
|
|
6651
|
-
const key = trimmed.slice(0, sep).trim();
|
|
6652
|
-
const value = trimmed.slice(sep + 1).trim();
|
|
6653
|
-
if (!key || !value) continue;
|
|
6654
|
-
headers[key] = value;
|
|
6655
|
-
}
|
|
6656
|
-
|
|
6657
|
-
return headers;
|
|
6658
|
-
}
|
|
6659
5771
|
|
|
6660
5772
|
/**
|
|
5773
|
+
* @param {{id?: string, accountUuid?: string} | null | undefined} account
|
|
6661
5774
|
* @returns {string}
|
|
6662
5775
|
*/
|
|
6663
|
-
function
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
* @returns {string | undefined}
|
|
6670
|
-
*/
|
|
6671
|
-
function logTransformedSystemPrompt(body) {
|
|
6672
|
-
if (!shouldDebugSystemPrompt()) return;
|
|
6673
|
-
if (!body || typeof body !== "string") return;
|
|
6674
|
-
|
|
6675
|
-
try {
|
|
6676
|
-
const parsed = JSON.parse(body);
|
|
6677
|
-
if (!Object.prototype.hasOwnProperty.call(parsed, "system")) return;
|
|
6678
|
-
if (isTitleGeneratorSystemBlocks(normalizeSystemTextBlocks(parsed.system))) return;
|
|
6679
|
-
console.error(
|
|
6680
|
-
"[opencode-anthropic-auth][system-debug] transformed system:",
|
|
6681
|
-
JSON.stringify(parsed.system, null, 2),
|
|
6682
|
-
);
|
|
6683
|
-
} catch {
|
|
6684
|
-
// Ignore parse errors in debug logging path.
|
|
5776
|
+
function getAccountIdentifier(account) {
|
|
5777
|
+
// Prefer env-provided account UUID (v2.1.51+), then account record fields
|
|
5778
|
+
const envUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID?.trim();
|
|
5779
|
+
if (envUuid) return envUuid;
|
|
5780
|
+
if (account?.accountUuid && typeof account.accountUuid === "string") {
|
|
5781
|
+
return account.accountUuid;
|
|
6685
5782
|
}
|
|
5783
|
+
if (account?.id && typeof account.id === "string") {
|
|
5784
|
+
return account.id;
|
|
5785
|
+
}
|
|
5786
|
+
return "";
|
|
6686
5787
|
}
|
|
6687
5788
|
|
|
6688
5789
|
/**
|
|
6689
|
-
* @param {string
|
|
6690
|
-
* @returns {
|
|
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}
|
|
5790
|
+
* @param {{persistentUserId: string, accountId: string, sessionId: string}} input
|
|
5791
|
+
* @returns {{user_id: string}}
|
|
6738
5792
|
*/
|
|
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
|
|
7878
|
-
* @param {string} model
|
|
7879
|
-
* @returns {any}
|
|
7880
|
-
*/
|
|
7881
|
-
function normalizeThinkingBlock(thinking, model) {
|
|
7882
|
-
// If thinking is absent or not an object, pass through
|
|
7883
|
-
if (!thinking || typeof thinking !== "object") {
|
|
7884
|
-
return thinking;
|
|
7885
|
-
}
|
|
7886
|
-
|
|
7887
|
-
// Adaptive thinking models always get { type: "adaptive" }
|
|
7888
|
-
// regardless of what format the incoming thinking block has
|
|
7889
|
-
if (isAdaptiveThinkingModel(model)) {
|
|
7890
|
-
// Check for env-var override to force budget_tokens fallback
|
|
7891
|
-
if (isTruthyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
|
|
7892
|
-
// Fallback: return as-is if already budget_tokens shape, otherwise default
|
|
7893
|
-
if (thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
|
|
7894
|
-
return thinking;
|
|
7895
|
-
}
|
|
7896
|
-
const parsedBudget = parseInt(process.env.MAX_THINKING_TOKENS, 10);
|
|
7897
|
-
return { type: "enabled", budget_tokens: Number.isNaN(parsedBudget) ? 16000 : parsedBudget };
|
|
7898
|
-
}
|
|
7899
|
-
return { type: "adaptive" };
|
|
7900
|
-
}
|
|
7901
|
-
|
|
7902
|
-
// Non-adaptive models: pass through unchanged
|
|
7903
|
-
return thinking;
|
|
7904
|
-
}
|
|
7905
|
-
|
|
7906
|
-
/**
|
|
7907
|
-
* Map Node.js platform to Stainless OS header value.
|
|
7908
|
-
* @param {NodeJS.Platform} value
|
|
7909
|
-
* @returns {string}
|
|
7910
|
-
*/
|
|
7911
|
-
function getStainlessOs(value) {
|
|
7912
|
-
if (value === "darwin") return "macOS";
|
|
7913
|
-
if (value === "win32") return "Windows";
|
|
7914
|
-
if (value === "linux") return "Linux";
|
|
7915
|
-
return value;
|
|
7916
|
-
}
|
|
7917
|
-
|
|
7918
|
-
/**
|
|
7919
|
-
* Normalize Node.js arch to Stainless arch header value.
|
|
7920
|
-
* @param {string} value
|
|
7921
|
-
* @returns {string}
|
|
7922
|
-
*/
|
|
7923
|
-
function getStainlessArch(value) {
|
|
7924
|
-
if (value === "x64") return "x64";
|
|
7925
|
-
if (value === "arm64") return "arm64";
|
|
7926
|
-
return value;
|
|
7927
|
-
}
|
|
7928
|
-
|
|
7929
|
-
/**
|
|
7930
|
-
* Resolve latest claude-code package version from npm registry.
|
|
7931
|
-
* Returns null on timeout/network/parse failures.
|
|
7932
|
-
* @param {number} timeoutMs
|
|
7933
|
-
* @returns {Promise<string | null>}
|
|
7934
|
-
*/
|
|
7935
|
-
async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
|
|
7936
|
-
const controller = new AbortController();
|
|
7937
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
7938
|
-
try {
|
|
7939
|
-
const response = await fetch(CLAUDE_CODE_NPM_LATEST_URL, {
|
|
7940
|
-
method: "GET",
|
|
7941
|
-
headers: { accept: "application/json" },
|
|
7942
|
-
signal: controller.signal,
|
|
7943
|
-
});
|
|
7944
|
-
if (!response.ok) return null;
|
|
7945
|
-
const data = await response.json();
|
|
7946
|
-
if (!data || typeof data !== "object") return null;
|
|
7947
|
-
return typeof data.version === "string" && data.version ? data.version : null;
|
|
7948
|
-
} catch {
|
|
7949
|
-
return null;
|
|
7950
|
-
} finally {
|
|
7951
|
-
clearTimeout(timeout);
|
|
7952
|
-
}
|
|
7953
|
-
}
|
|
7954
|
-
|
|
7955
|
-
/**
|
|
7956
|
-
* Build request headers from input and init, applying OAuth requirements.
|
|
7957
|
-
* Preserves behaviors D1-D7.
|
|
7958
|
-
*
|
|
7959
|
-
* @param {any} input
|
|
7960
|
-
* @param {Record<string, any>} requestInit
|
|
7961
|
-
* @param {string} accessToken
|
|
7962
|
-
* @param {string | undefined} requestBody
|
|
7963
|
-
* @param {URL | null} requestUrl
|
|
7964
|
-
* @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[], sessionId?: string}} signature
|
|
7965
|
-
* @returns {Headers}
|
|
7966
|
-
*/
|
|
7967
|
-
function buildRequestHeaders(
|
|
7968
|
-
input,
|
|
7969
|
-
requestInit,
|
|
7970
|
-
accessToken,
|
|
7971
|
-
requestBody,
|
|
7972
|
-
requestUrl,
|
|
7973
|
-
signature,
|
|
7974
|
-
adaptiveOverride,
|
|
7975
|
-
tokenEconomy,
|
|
7976
|
-
) {
|
|
7977
|
-
const requestHeaders = new Headers();
|
|
7978
|
-
if (input instanceof Request) {
|
|
7979
|
-
input.headers.forEach((value, key) => {
|
|
7980
|
-
requestHeaders.set(key, value);
|
|
7981
|
-
});
|
|
7982
|
-
}
|
|
7983
|
-
if (requestInit.headers) {
|
|
7984
|
-
if (requestInit.headers instanceof Headers) {
|
|
7985
|
-
requestInit.headers.forEach((value, key) => {
|
|
7986
|
-
requestHeaders.set(key, value);
|
|
7987
|
-
});
|
|
7988
|
-
} else if (Array.isArray(requestInit.headers)) {
|
|
7989
|
-
for (const [key, value] of requestInit.headers) {
|
|
7990
|
-
if (typeof value !== "undefined") {
|
|
7991
|
-
requestHeaders.set(key, String(value));
|
|
7992
|
-
}
|
|
7993
|
-
}
|
|
7994
|
-
} else {
|
|
7995
|
-
for (const [key, value] of Object.entries(requestInit.headers)) {
|
|
7996
|
-
if (typeof value !== "undefined") {
|
|
7997
|
-
requestHeaders.set(key, String(value));
|
|
7998
|
-
}
|
|
7999
|
-
}
|
|
8000
|
-
}
|
|
8001
|
-
}
|
|
8002
|
-
|
|
8003
|
-
// Preserve all incoming beta headers while ensuring OAuth requirements
|
|
8004
|
-
const incomingBeta = requestHeaders.get("anthropic-beta") || "";
|
|
8005
|
-
const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
|
|
8006
|
-
const provider = detectProvider(requestUrl);
|
|
8007
|
-
// Detect fast mode structurally from the already-transformed body string.
|
|
8008
|
-
// transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
|
|
8009
|
-
// so speed:"fast" is already present when fast mode fired. Same detection idiom as the
|
|
8010
|
-
// toast check (~line 3018). This is the correct call site for fast-mode: the
|
|
8011
|
-
// computedBetaHeader call at index.mjs:2905 runs BEFORE body transform, so fastModeActive
|
|
8012
|
-
// is always false/absent there, keeping the latch free of fast-mode. The
|
|
8013
|
-
// requestHeaders.set("anthropic-beta", mergedBetas) call below is what reaches the wire.
|
|
8014
|
-
const fastModeActive = typeof requestBody === "string" && requestBody.includes('"speed":"fast"');
|
|
8015
|
-
const mergedBetas = buildAnthropicBetaHeader(
|
|
8016
|
-
incomingBeta,
|
|
8017
|
-
signature.enabled,
|
|
8018
|
-
model,
|
|
8019
|
-
provider,
|
|
8020
|
-
signature.customBetas,
|
|
8021
|
-
signature.strategy,
|
|
8022
|
-
requestUrl?.pathname,
|
|
8023
|
-
hasFileReferences,
|
|
8024
|
-
adaptiveOverride,
|
|
8025
|
-
tokenEconomy,
|
|
8026
|
-
undefined, // microcompactBetas: not available at this call site (handled via computedBetaHeader path at 2905)
|
|
8027
|
-
fastModeActive, // NEW 12th param: emit fast-mode-2026-02-01 beta when body contains speed:"fast"
|
|
8028
|
-
);
|
|
8029
|
-
|
|
8030
|
-
const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
8031
|
-
const bearerToken = authTokenOverride || accessToken;
|
|
8032
|
-
|
|
8033
|
-
requestHeaders.set("authorization", `Bearer ${bearerToken}`);
|
|
8034
|
-
requestHeaders.set("anthropic-beta", mergedBetas);
|
|
8035
|
-
requestHeaders.set("user-agent", buildExtendedUserAgent(signature.claudeCliVersion));
|
|
8036
|
-
if (signature.enabled) {
|
|
8037
|
-
requestHeaders.set("anthropic-version", "2023-06-01");
|
|
8038
|
-
// Fix #6: x-app is "cli" for interactive mode, "cli-bg" for background tasks.
|
|
8039
|
-
// Real CC (client.ts:106): 'x-app': 'cli' (foreground) or 'cli-bg' (background agent).
|
|
8040
|
-
requestHeaders.set("x-app", isTruthyEnv(process.env.CLAUDE_CODE_BACKGROUND) ? "cli-bg" : "cli");
|
|
8041
|
-
// Fix #3: X-Claude-Code-Session-Id — sent in ALL requests by real CC (client.ts:108).
|
|
8042
|
-
// Value matches metadata.user_id.session_id for server-side correlation.
|
|
8043
|
-
if (signature.sessionId) {
|
|
8044
|
-
requestHeaders.set("X-Claude-Code-Session-Id", signature.sessionId);
|
|
8045
|
-
}
|
|
8046
|
-
requestHeaders.set("x-stainless-arch", getStainlessArch(process.arch));
|
|
8047
|
-
requestHeaders.set("x-stainless-lang", "js");
|
|
8048
|
-
requestHeaders.set("x-stainless-os", getStainlessOs(process.platform));
|
|
8049
|
-
// Real CC sends 0.81.0 (confirmed via proxy capture), not the internal 0.208.0.
|
|
8050
|
-
// WATCH: most-likely-to-drift mimesis constant. Stable v2.1.97 → v2.1.105 (only
|
|
8051
|
-
// the minifier identifier renamed, d66 → g86). Re-verify on every upstream bump:
|
|
8052
|
-
// rg -n '"0\.\d+\.\d+"' _tmp_claude_pkg/<version>/package/cli.js | rg -C2 stainless
|
|
8053
|
-
// See docs/future-improvements.md §7 and claude-code-reverse-engineering.md §16.
|
|
8054
|
-
requestHeaders.set("x-stainless-package-version", "0.94.0");
|
|
8055
|
-
// Real CC on Windows/Node reports "node" — confirmed via proxy capture.
|
|
8056
|
-
requestHeaders.set("x-stainless-runtime", "node");
|
|
8057
|
-
requestHeaders.set("x-stainless-runtime-version", process.version);
|
|
8058
|
-
const incomingRetryCount = requestHeaders.get("x-stainless-retry-count");
|
|
8059
|
-
requestHeaders.set(
|
|
8060
|
-
"x-stainless-retry-count",
|
|
8061
|
-
incomingRetryCount && !isFalsyEnv(incomingRetryCount) ? incomingRetryCount : "0",
|
|
8062
|
-
);
|
|
8063
|
-
// x-stainless-timeout: real CC sends 600 on ALL requests (confirmed via proxy capture).
|
|
8064
|
-
requestHeaders.set("x-stainless-timeout", "600");
|
|
8065
|
-
// anthropic-dangerous-direct-browser-access: real CC sends this on all requests.
|
|
8066
|
-
requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
|
|
8067
|
-
const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
|
|
8068
|
-
if (stainlessHelpers) {
|
|
8069
|
-
requestHeaders.set("x-stainless-helper", stainlessHelpers);
|
|
8070
|
-
}
|
|
8071
|
-
|
|
8072
|
-
for (const [key, value] of Object.entries(parseAnthropicCustomHeaders())) {
|
|
8073
|
-
requestHeaders.set(key, value);
|
|
8074
|
-
}
|
|
8075
|
-
if (process.env.CLAUDE_CODE_CONTAINER_ID) {
|
|
8076
|
-
requestHeaders.set("x-claude-remote-container-id", process.env.CLAUDE_CODE_CONTAINER_ID);
|
|
8077
|
-
}
|
|
8078
|
-
if (process.env.CLAUDE_CODE_REMOTE_SESSION_ID) {
|
|
8079
|
-
requestHeaders.set("x-claude-remote-session-id", process.env.CLAUDE_CODE_REMOTE_SESSION_ID);
|
|
8080
|
-
}
|
|
8081
|
-
if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) {
|
|
8082
|
-
requestHeaders.set("x-client-app", process.env.CLAUDE_AGENT_SDK_CLIENT_APP);
|
|
8083
|
-
}
|
|
8084
|
-
if (isTruthyEnv(process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) {
|
|
8085
|
-
requestHeaders.set("x-anthropic-additional-protection", "true");
|
|
8086
|
-
}
|
|
8087
|
-
|
|
8088
|
-
// x-client-request-id: real CC 2.1.195's first-party fetch middleware (Ukd) sets
|
|
8089
|
-
// this to crypto.randomUUID() on every first-party request. Re-emit a random uuid.
|
|
8090
|
-
requestHeaders.set("x-client-request-id", randomUUID());
|
|
8091
|
-
}
|
|
8092
|
-
requestHeaders.delete("x-api-key");
|
|
8093
|
-
// x-session-affinity: set by opencode SDK but NOT in real CC. Strip it.
|
|
8094
|
-
requestHeaders.delete("x-session-affinity");
|
|
8095
|
-
|
|
8096
|
-
return requestHeaders;
|
|
8097
|
-
}
|
|
8098
|
-
|
|
8099
|
-
/**
|
|
8100
|
-
* Resolve max_tokens for a request based on output cap configuration.
|
|
8101
|
-
* If the caller specified max_tokens, it is preserved. Otherwise, defaults
|
|
8102
|
-
* to 8K and escalates to 64K after an output truncation (stop_reason: "max_tokens").
|
|
8103
|
-
*
|
|
8104
|
-
* @param {Record<string, any>} body - Parsed request body
|
|
8105
|
-
* @param {import('./lib/config.mjs').AnthropicAuthConfig} config
|
|
8106
|
-
* @returns {number | undefined} Resolved max_tokens value, or undefined for passthrough
|
|
8107
|
-
*/
|
|
8108
|
-
function resolveMaxTokens(body, config) {
|
|
8109
|
-
if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
|
|
8110
|
-
if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
|
|
8111
|
-
// QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
|
|
8112
|
-
// completes. The timing works correctly for "escalate for one turn" because this function runs
|
|
8113
|
-
// BEFORE the next request's stream starts. If the response pipeline changes to update stop
|
|
8114
|
-
// reason mid-stream or before response completion, this ordering assumption would break.
|
|
8115
|
-
const escalated = sessionMetrics.lastStopReason === "max_tokens";
|
|
8116
|
-
const result = escalated
|
|
8117
|
-
? (config.output_cap.escalated_max_tokens ?? 64_000)
|
|
8118
|
-
: (config.output_cap.default_max_tokens ?? 8_000);
|
|
8119
|
-
// Reset after escalation is consumed (sticky for exactly one turn)
|
|
8120
|
-
if (escalated) {
|
|
8121
|
-
sessionMetrics.lastStopReason = null;
|
|
8122
|
-
}
|
|
8123
|
-
return result;
|
|
8124
|
-
}
|
|
8125
|
-
|
|
8126
|
-
/**
|
|
8127
|
-
* Transform the request body: system prompt sanitization and tool prefixing.
|
|
8128
|
-
* Preserves behaviors E1-E7.
|
|
8129
|
-
*
|
|
8130
|
-
* @param {string | undefined} body
|
|
8131
|
-
* @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
|
|
8132
|
-
* @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
|
|
8133
|
-
* @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
|
|
8134
|
-
* @param {import('./lib/config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
|
|
8135
|
-
* @returns {string | undefined}
|
|
8136
|
-
*/
|
|
8137
|
-
function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
8138
|
-
if (!body || typeof body !== "string") return body;
|
|
8139
|
-
|
|
8140
|
-
const TOOL_PREFIX = "mcp_";
|
|
8141
|
-
|
|
8142
|
-
try {
|
|
8143
|
-
const parsed = JSON.parse(body);
|
|
8144
|
-
// Diagnostic: log the incoming model + thinking/speed shape so model-routing
|
|
8145
|
-
// issues (e.g. an unrecognized fast variant, or "No available account"
|
|
8146
|
-
// symptoms tied to a specific model) are observable with /anthropic set debug on.
|
|
8147
|
-
// NOTE: transformRequestBody is a top-level function outside the plugin
|
|
8148
|
-
// closure where debugLog() lives, so we mirror debugLog's behaviour directly
|
|
8149
|
-
// (console.error + same prefix, gated on config.debug).
|
|
8150
|
-
if (config?.debug) {
|
|
8151
|
-
console.error("[opencode-anthropic-auth]", "transformRequestBody: incoming model", {
|
|
8152
|
-
model: parsed.model,
|
|
8153
|
-
adaptive: isAdaptiveThinkingModel(parsed.model || ""),
|
|
8154
|
-
thinkingType: parsed.thinking?.type,
|
|
8155
|
-
hasEffort: parsed.effort !== undefined || parsed.output_config?.effort !== undefined,
|
|
8156
|
-
speed: parsed.speed,
|
|
8157
|
-
});
|
|
8158
|
-
}
|
|
8159
|
-
// Output cap: resolve max_tokens before any other body transforms
|
|
8160
|
-
if (config?.output_cap?.enabled) {
|
|
8161
|
-
parsed.max_tokens = resolveMaxTokens(parsed, config);
|
|
8162
|
-
}
|
|
8163
|
-
// Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
|
|
8164
|
-
// doesn't forward custom HTTP headers. First-party API rejects "betas" in body
|
|
8165
|
-
// with "Extra inputs are not permitted" — betas are header-only for first-party.
|
|
8166
|
-
if (signature.enabled && betaHeader && signature.provider === "bedrock") {
|
|
8167
|
-
const betaArray = betaHeader
|
|
8168
|
-
.split(",")
|
|
8169
|
-
.map((b) => b.trim())
|
|
8170
|
-
.filter(Boolean)
|
|
8171
|
-
.filter((b) => b !== "oauth-2025-04-20");
|
|
8172
|
-
parsed.anthropic_beta = betaArray;
|
|
8173
|
-
}
|
|
8174
|
-
// Strip any incoming "betas" field — API rejects it as unknown
|
|
8175
|
-
if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
|
|
8176
|
-
delete parsed.betas;
|
|
8177
|
-
}
|
|
8178
|
-
// Normalize thinking block for adaptive (Opus 4.6 / Sonnet 4.6) vs manual (older models).
|
|
8179
|
-
// Real CC always sends thinking:{type:"adaptive"} for adaptive models even if the
|
|
8180
|
-
// upstream SDK didn't include it. Inject it when missing to match the fingerprint.
|
|
8181
|
-
if (Object.prototype.hasOwnProperty.call(parsed, "thinking")) {
|
|
8182
|
-
parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
|
|
8183
|
-
} else if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
|
|
8184
|
-
parsed.thinking = { type: "adaptive" };
|
|
8185
|
-
}
|
|
8186
|
-
|
|
8187
|
-
// Fingerprint fix: real Claude Code v2.1.87+ nests the effort control inside
|
|
8188
|
-
// `output_config.effort` (via Lyz() in cli.js). opencode's provider transform
|
|
8189
|
-
// for variant=max on Opus 4.6 / Sonnet 4.6 sets `effort` at the top level,
|
|
8190
|
-
// which causes Anthropic's server to fingerprint the body as non-CC and bill
|
|
8191
|
-
// it as pay-as-you-go — surfacing as "You're out of extra usage" even on a
|
|
8192
|
-
// valid Max plan. Move it into output_config when we're talking to an
|
|
8193
|
-
// adaptive-thinking model so the wire shape matches real CC.
|
|
8194
|
-
if (typeof parsed.effort === "string" && parsed.model && isAdaptiveThinkingModel(parsed.model)) {
|
|
8195
|
-
if (!parsed.output_config || typeof parsed.output_config !== "object") {
|
|
8196
|
-
parsed.output_config = {};
|
|
8197
|
-
}
|
|
8198
|
-
if (!("effort" in parsed.output_config)) {
|
|
8199
|
-
parsed.output_config.effort = parsed.effort;
|
|
8200
|
-
}
|
|
8201
|
-
delete parsed.effort;
|
|
8202
|
-
} else if (Object.prototype.hasOwnProperty.call(parsed, "effort")) {
|
|
8203
|
-
// Non-adaptive models never carry a top-level effort in real CC — strip it
|
|
8204
|
-
// to avoid polluting the fingerprint for models like Haiku.
|
|
8205
|
-
delete parsed.effort;
|
|
8206
|
-
}
|
|
8207
|
-
|
|
8208
|
-
// Claude Code v2.1.117+: default effort for Pro/Max on adaptive models is
|
|
8209
|
-
// "high" (was "medium"). If the host omits effort entirely, inject the
|
|
8210
|
-
// default so the wire shape matches real CC.
|
|
8211
|
-
if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
|
|
8212
|
-
if (!parsed.output_config || typeof parsed.output_config !== "object") {
|
|
8213
|
-
parsed.output_config = {};
|
|
8214
|
-
}
|
|
8215
|
-
if (!("effort" in parsed.output_config)) {
|
|
8216
|
-
parsed.output_config.effort = "high";
|
|
8217
|
-
}
|
|
8218
|
-
}
|
|
8219
|
-
|
|
8220
|
-
// Claude Code temperature rule: when extended thinking is active (any type),
|
|
8221
|
-
// temperature must be omitted (undefined). Otherwise default to 1.
|
|
8222
|
-
const thinkingActive =
|
|
8223
|
-
parsed.thinking &&
|
|
8224
|
-
typeof parsed.thinking === "object" &&
|
|
8225
|
-
(parsed.thinking.type === "adaptive" || parsed.thinking.type === "enabled");
|
|
8226
|
-
if (thinkingActive) {
|
|
8227
|
-
// Anthropic API rejects temperature when thinking is enabled
|
|
8228
|
-
delete parsed.temperature;
|
|
8229
|
-
|
|
8230
|
-
// Claude Code v2.1.84: inject the context_management body field ONLY when the
|
|
8231
|
-
// user has explicitly opted in via token_economy.context_management. Note: as of
|
|
8232
|
-
// v2.1.195 the context-management *beta header* is default-ON for first-party
|
|
8233
|
-
// non-claude-3 models (see buildAnthropicBetaHeader), but the body field stays
|
|
8234
|
-
// opt-in. A top-level context_management field WITHOUT the beta is rejected with
|
|
8235
|
-
// "context_management: Extra inputs are not permitted"; the beta being present
|
|
8236
|
-
// without the field is fine. Gating the field on explicit opt-in keeps the field
|
|
8237
|
-
// ⊆ beta invariant, so the 400 never fires.
|
|
8238
|
-
if (
|
|
8239
|
-
config?.token_economy?.context_management &&
|
|
8240
|
-
!CLAUDE_3_MODEL_RE.test(parsed.model || "") &&
|
|
8241
|
-
!parsed.context_management
|
|
8242
|
-
) {
|
|
8243
|
-
parsed.context_management = {
|
|
8244
|
-
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
|
8245
|
-
};
|
|
8246
|
-
}
|
|
8247
|
-
} else {
|
|
8248
|
-
// Claude Code always uses temperature: 1 for non-thinking requests (RE doc §5.2, never 0)
|
|
8249
|
-
parsed.temperature = 1;
|
|
8250
|
-
}
|
|
8251
|
-
|
|
8252
|
-
// Strip leaked /anthropic slash command messages from conversation history.
|
|
8253
|
-
// OpenCode may include command text and sendCommandMessage output as regular
|
|
8254
|
-
// user messages even when output.noReply = true was set. Filter them out
|
|
8255
|
-
// so the agent never sees /anthropic commands in its context.
|
|
8256
|
-
if (Array.isArray(parsed.messages)) {
|
|
8257
|
-
parsed.messages = stripSlashCommandMessages(parsed.messages);
|
|
8258
|
-
}
|
|
8259
|
-
|
|
8260
|
-
// === Token economy: layered message/history compaction ===
|
|
8261
|
-
// Only applies to main-thread requests (subagents/title-gen stay untouched).
|
|
8262
|
-
// Strategies stack in order: TTL thinking strip → proactive microcompact →
|
|
8263
|
-
// trailing-summary trim → tool_result dedupe. Each is independently gated.
|
|
8264
|
-
const te = config?.token_economy || {};
|
|
8265
|
-
const tes = runtime?.tokenEconomySession;
|
|
8266
|
-
const isMainRole = runtime?.requestRole === "main" || runtime?.requestRole == null;
|
|
8267
|
-
|
|
8268
|
-
// `conservative` (default ON) disables all history-rewriting and tool-array
|
|
8269
|
-
// transforms. These optimizations shrink each request body but cause the
|
|
8270
|
-
// prompt-cache prefix to change turn-to-turn, invalidating the 1h cache
|
|
8271
|
-
// and forcing a fresh cache_write each turn — which costs 2x base input
|
|
8272
|
-
// tokens. For long opencode sessions, cache reuse dominates; flip to
|
|
8273
|
-
// `false` only if you have measurements showing otherwise. Adaptive
|
|
8274
|
-
// thinking zero-out remains active (affects only the thinking budget,
|
|
8275
|
-
// not cached content).
|
|
8276
|
-
const conservative = te.conservative !== false;
|
|
8277
|
-
|
|
8278
|
-
if (!conservative && isMainRole && Array.isArray(parsed.messages) && tes) {
|
|
8279
|
-
// (1) TTL-based thinking strip
|
|
8280
|
-
if (te.ttl_thinking_strip !== false) {
|
|
8281
|
-
const ttlMs = signature?.cachePolicy?.ttl === "5m" ? 5 * 60_000 : 60 * 60_000;
|
|
8282
|
-
const res = applyTtlThinkingStrip(parsed.messages, {
|
|
8283
|
-
lastClearMs: tes.lastThinkingStripMs,
|
|
8284
|
-
ttlMs,
|
|
8285
|
-
});
|
|
8286
|
-
if (res.changed) {
|
|
8287
|
-
parsed.messages = res.messages;
|
|
8288
|
-
tes.lastThinkingStripMs = res.ranStripAt;
|
|
8289
|
-
tes.thinkingStripped += res.cleared;
|
|
8290
|
-
}
|
|
8291
|
-
}
|
|
8292
|
-
|
|
8293
|
-
// (1b) Session-wide reproducible-tool result dedupe (Phase C C3, opt-in)
|
|
8294
|
-
// Pure over message history; runs before microcompact so dedup'd stubs
|
|
8295
|
-
// are visible to downstream size estimation. Gated by
|
|
8296
|
-
// token_economy_strategies.tool_result_dedupe_session_wide (default off).
|
|
8297
|
-
if (config?.token_economy_strategies?.tool_result_dedupe_session_wide === true) {
|
|
8298
|
-
const res = applySessionToolResultDedupe(parsed.messages);
|
|
8299
|
-
if (res.changed) parsed.messages = res.messages;
|
|
8300
|
-
}
|
|
8301
|
-
|
|
8302
|
-
// (2) Proactive microcompact (client-side, pre-422)
|
|
8303
|
-
if (te.proactive_microcompact !== false) {
|
|
8304
|
-
const estimated = estimatePromptTokensFromParsed(parsed);
|
|
8305
|
-
const cw = 200_000; // conservative — 1M models still benefit
|
|
8306
|
-
const res = applyProactiveMicrocompact(parsed.messages, {
|
|
8307
|
-
estimatedTokens: estimated,
|
|
8308
|
-
contextWindow: cw,
|
|
8309
|
-
percent: te.microcompact_percent ?? 70,
|
|
8310
|
-
keepRecent: te.microcompact_keep_recent ?? 8,
|
|
8311
|
-
});
|
|
8312
|
-
if (res.changed) {
|
|
8313
|
-
parsed.messages = res.messages;
|
|
8314
|
-
tes.lastMicrocompactMs = Date.now();
|
|
8315
|
-
tes.toolResultsCompacted += res.cleared;
|
|
8316
|
-
}
|
|
8317
|
-
}
|
|
8318
|
-
|
|
8319
|
-
// (3) Trailing-summary trim (opt-in)
|
|
8320
|
-
if (te.trailing_summary_trim === true) {
|
|
8321
|
-
const res = applyTrailingSummaryTrim(parsed.messages);
|
|
8322
|
-
if (res.changed) parsed.messages = res.messages;
|
|
8323
|
-
}
|
|
8324
|
-
|
|
8325
|
-
// (4) Cross-turn tool_result dedupe (opt-in)
|
|
8326
|
-
if (te.tool_result_dedupe === true) {
|
|
8327
|
-
const SAFE_READ_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "BashOutput"]);
|
|
8328
|
-
const res = applyToolResultDedupe(parsed.messages, {
|
|
8329
|
-
seen: tes.seenContentHashes,
|
|
8330
|
-
safeTools: SAFE_READ_TOOLS,
|
|
8331
|
-
});
|
|
8332
|
-
if (res.changed) parsed.messages = res.messages;
|
|
8333
|
-
}
|
|
8334
|
-
}
|
|
8335
|
-
|
|
8336
|
-
// === Token economy: tool-array transforms (stable ordering, deferral) ===
|
|
8337
|
-
if (!conservative && isMainRole && Array.isArray(parsed.tools)) {
|
|
8338
|
-
if (te.stable_tool_ordering !== false) {
|
|
8339
|
-
parsed.tools = applyStableToolOrdering(parsed.tools);
|
|
8340
|
-
}
|
|
8341
|
-
if (Array.isArray(te.deferred_tool_names) && te.deferred_tool_names.length > 0) {
|
|
8342
|
-
// "Invoked" means any assistant message in the convo has used the tool.
|
|
8343
|
-
const invoked = new Set();
|
|
8344
|
-
if (Array.isArray(parsed.messages)) {
|
|
8345
|
-
for (const m of parsed.messages) {
|
|
8346
|
-
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
8347
|
-
for (const b of m.content) {
|
|
8348
|
-
if (b?.type === "tool_use" && typeof b.name === "string") invoked.add(b.name);
|
|
8349
|
-
}
|
|
8350
|
-
}
|
|
8351
|
-
}
|
|
8352
|
-
const res = applyToolSchemaDeferral(parsed.tools, {
|
|
8353
|
-
deferred: new Set(te.deferred_tool_names),
|
|
8354
|
-
invoked,
|
|
8355
|
-
});
|
|
8356
|
-
parsed.tools = res.tools;
|
|
8357
|
-
}
|
|
8358
|
-
}
|
|
8359
|
-
|
|
8360
|
-
// === Token economy: adaptive thinking zero-out for trivial follow-ups ===
|
|
8361
|
-
if (isMainRole && te.adaptive_thinking_zero_simple !== false) {
|
|
8362
|
-
applyAdaptiveThinkingZero(parsed);
|
|
8363
|
-
}
|
|
8364
|
-
|
|
8365
|
-
// QA fix H2: avoid mutating the signature parameter; capture modelId locally
|
|
8366
|
-
const modelId = parsed.model || "";
|
|
8367
|
-
// Extract first user message text for billing hash computation (cch)
|
|
8368
|
-
const firstUserMessage = extractFirstUserMessageText(parsed.messages);
|
|
8369
|
-
// Resolve the prompt-cache TTL ONCE so system blocks, tools, and messages
|
|
8370
|
-
// all share the SAME ttl. Anthropic processes cache_control blocks in the
|
|
8371
|
-
// order tools → system → messages and rejects any ttl='1h' block that comes
|
|
8372
|
-
// AFTER a ttl='5m' block. Before this fix the role-scoped 5m downgrade was
|
|
8373
|
-
// applied only to tools/messages (resolveCacheTtl in the breakpoint loop)
|
|
8374
|
-
// while the system blocks kept the configured 1h — so subagent requests
|
|
8375
|
-
// (5m tools, then 1h system) tripped:
|
|
8376
|
-
// "system.1.cache_control.ttl: a ttl='1h' cache_control block must not
|
|
8377
|
-
// come after a ttl='5m' cache_control block".
|
|
8378
|
-
// Threading it through buildSystemPromptBlocks keeps every cache_control ttl
|
|
8379
|
-
// consistent (real CC derives the ttl uniformly from querySource).
|
|
8380
|
-
const baseCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
|
|
8381
|
-
const cachingEnabledForTtl = baseCachePolicy.ttl !== "off" && baseCachePolicy.ttl_supported !== false;
|
|
8382
|
-
const resolvedCacheTtl = cachingEnabledForTtl
|
|
8383
|
-
? resolveCacheTtl({
|
|
8384
|
-
configuredTtl: baseCachePolicy.ttl || "1h",
|
|
8385
|
-
roleScopedTtl: config?.token_economy?.role_scoped_cache_ttl !== false,
|
|
8386
|
-
isMainForCache: runtime?.requestRole === "main" || runtime?.requestRole == null,
|
|
8387
|
-
isSubagent: runtime?.isSubagent === true,
|
|
8388
|
-
env: process.env,
|
|
8389
|
-
})
|
|
8390
|
-
: baseCachePolicy.ttl;
|
|
8391
|
-
const signatureWithModel = {
|
|
8392
|
-
...signature,
|
|
8393
|
-
modelId,
|
|
8394
|
-
// Override cachePolicy.ttl with the role/subagent-resolved ttl so the
|
|
8395
|
-
// system blocks match the tool/message breakpoint ttl (see comment above).
|
|
8396
|
-
cachePolicy: { ...baseCachePolicy, ttl: resolvedCacheTtl },
|
|
8397
|
-
firstUserMessage,
|
|
8398
|
-
antiVerbosity: config?.anti_verbosity,
|
|
8399
|
-
// Role-aware system-prompt leaning: for non-main-thread requests (title,
|
|
8400
|
-
// small, empty shapes) strip billing identity + CC identity injection.
|
|
8401
|
-
// Title-gen path is handled separately by isTitleGeneratorSystemBlocks().
|
|
8402
|
-
// Default off — opt-in via `token_economy.lean_system_non_main: true`.
|
|
8403
|
-
requestRole: runtime?.requestRole,
|
|
8404
|
-
leanNonMain: config?.token_economy?.lean_system_non_main === true,
|
|
8405
|
-
// Simple-system-prompt mode for Opus 4.7+ (CC v2.1.133+ parity, gated by
|
|
8406
|
-
// GrowthBook `tengu_vellum_lantern` in real CC). Plugin gate: model
|
|
8407
|
-
// eligibility + opt-in flag. See buildSystemPromptBlocks for what it
|
|
8408
|
-
// strips (anti-verbosity boilerplate only; identity/billing untouched).
|
|
8409
|
-
simpleSystemPrompt: config?.token_economy?.simple_system_prompt === true,
|
|
8410
|
-
// Workload tag for x-anthropic-billing-header `cc_workload=` segment.
|
|
8411
|
-
// Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
|
|
8412
|
-
// SDK daemon callers spawning cron subprocesses). Empty string -> omit.
|
|
8413
|
-
workload: typeof config?.signature_emulation?.workload === "string" ? config.signature_emulation.workload : "",
|
|
8414
|
-
};
|
|
8415
|
-
// Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
|
|
8416
|
-
parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
|
|
8417
|
-
|
|
8418
|
-
// Strategy 5 — System prompt tailing: after N turns, trim large system blocks
|
|
8419
|
-
// to essential sections only. The model has internalized verbose instructions
|
|
8420
|
-
// (shell strategy, package manager tables, delegation protocols) by this point.
|
|
8421
|
-
// Preserves: first paragraph (identity/role), lines containing MUST/NEVER/CRITICAL/
|
|
8422
|
-
// IMPORTANT, section headers, and short blocks. Drops verbose body paragraphs.
|
|
8423
|
-
const tailThreshold = signature.systemPromptTailTurns ?? 6;
|
|
8424
|
-
if (signature.systemPromptTailing === true && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
|
|
8425
|
-
const maxChars = signature.systemPromptTailMaxChars ?? 2000;
|
|
8426
|
-
for (let i = 0; i < parsed.system.length; i++) {
|
|
8427
|
-
const block = parsed.system[i];
|
|
8428
|
-
if (block.type === "text" && block.text && block.text.length > maxChars * 2) {
|
|
8429
|
-
block.text = tailSystemBlock(block.text, maxChars, tailThreshold);
|
|
8430
|
-
}
|
|
8431
|
-
}
|
|
8432
|
-
}
|
|
8433
|
-
|
|
8434
|
-
// Token budget (A9): parse NL budget from last user message, inject status block
|
|
8435
|
-
if (config?.token_budget?.enabled && Array.isArray(parsed.messages)) {
|
|
8436
|
-
const budgetExpr = parseNaturalLanguageBudget(parsed.messages);
|
|
8437
|
-
if (budgetExpr > 0) {
|
|
8438
|
-
sessionMetrics.tokenBudget.limit = budgetExpr;
|
|
8439
|
-
} else if (config.token_budget.default > 0 && sessionMetrics.tokenBudget.limit === 0) {
|
|
8440
|
-
sessionMetrics.tokenBudget.limit = config.token_budget.default;
|
|
8441
|
-
}
|
|
8442
|
-
// If budget is active, inject status into system prompt
|
|
8443
|
-
if (sessionMetrics.tokenBudget.limit > 0) {
|
|
8444
|
-
const threshold = config.token_budget.completion_threshold ?? 0.9;
|
|
8445
|
-
parsed.system = injectTokenBudgetBlock(parsed.system, sessionMetrics.tokenBudget, threshold);
|
|
8446
|
-
// Soft stop: if we've exceeded the threshold, cap max_tokens to 1
|
|
8447
|
-
if (sessionMetrics.tokenBudget.used >= sessionMetrics.tokenBudget.limit * threshold) {
|
|
8448
|
-
parsed.max_tokens = 1;
|
|
8449
|
-
}
|
|
8450
|
-
}
|
|
8451
|
-
}
|
|
8452
|
-
|
|
8453
|
-
if (signature.enabled) {
|
|
8454
|
-
const currentMetadata =
|
|
8455
|
-
parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
|
|
8456
|
-
? parsed.metadata
|
|
8457
|
-
: {};
|
|
8458
|
-
parsed.metadata = {
|
|
8459
|
-
...currentMetadata,
|
|
8460
|
-
...buildRequestMetadata({
|
|
8461
|
-
persistentUserId: runtime.persistentUserId,
|
|
8462
|
-
accountId: runtime.accountId,
|
|
8463
|
-
sessionId: runtime.sessionId,
|
|
8464
|
-
}),
|
|
8465
|
-
};
|
|
8466
|
-
}
|
|
8467
|
-
|
|
8468
|
-
// Cache breakpoint optimization: add cache_control to the last content block
|
|
8469
|
-
// of each user/assistant message for maximum prefix caching.
|
|
8470
|
-
// Skip for round-robin strategy (cache defeated by account rotation).
|
|
8471
|
-
// Skip for title generators / fire-and-forget queries: these are one-shot
|
|
8472
|
-
// requests that don't benefit from caching and would pollute the cache pool.
|
|
8473
|
-
const isTitleGen = isTitleGeneratorSystemBlocks(parsed.system || []);
|
|
8474
|
-
if (
|
|
8475
|
-
signature.enabled &&
|
|
8476
|
-
signature.cachePolicy?.ttl !== "off" &&
|
|
8477
|
-
signature.cachePolicy?.ttl_supported !== false &&
|
|
8478
|
-
!isTitleGen
|
|
8479
|
-
) {
|
|
8480
|
-
// Strip ALL incoming cache_control from tools and messages to prevent
|
|
8481
|
-
// TTL ordering violations (host SDK may set ttl=5m which conflicts with
|
|
8482
|
-
// our system prompt ttl=1h). Then add our own to the last user message
|
|
8483
|
-
// (matching real CC behavior seen in proxy capture).
|
|
8484
|
-
//
|
|
8485
|
-
// Role-scoped TTL: real CC's REH(querySource) decides the `ttl:"1h"` field.
|
|
8486
|
-
// Decompiled from CC 2.1.154 (function REH, offset 225174828), precedence:
|
|
8487
|
-
// 1. FORCE_PROMPT_CACHING_5M => 5m (highest priority)
|
|
8488
|
-
// 2. ENABLE_PROMPT_CACHING_1H => 1h
|
|
8489
|
-
// 3. no tengu support / overage => 5m
|
|
8490
|
-
// 4. querySource ∈ allowlist [repl_main_thread*, sdk, auto_mode, ...] => 1h, else 5m
|
|
8491
|
-
// We map step 4 via classifyRequestRole: main (interactive/auto_mode thread)
|
|
8492
|
-
// → 1h; side-queries (title-gen, etc.) → 5m. The env vars are honored
|
|
8493
|
-
// exactly as CC does, giving the user a manual override + mimicry fidelity.
|
|
8494
|
-
// Reuse the request-wide resolved ttl (computed above where
|
|
8495
|
-
// signatureWithModel is built) so tools/messages match the system blocks
|
|
8496
|
-
// and never trip the 1h-after-5m ordering rule on subagent requests.
|
|
8497
|
-
const ccTtl = resolvedCacheTtl;
|
|
8498
|
-
if (Array.isArray(parsed.tools) && parsed.tools.length > 0) {
|
|
8499
|
-
for (const tool of parsed.tools) {
|
|
8500
|
-
if (tool.cache_control) delete tool.cache_control;
|
|
8501
|
-
}
|
|
8502
|
-
// Adaptive breakpoint placement (opt-in, runtime-driven). Real CC always
|
|
8503
|
-
// pins the breakpoint on the last tool because CC controls a STABLE tool
|
|
8504
|
-
// array. opencode may reorder/add/remove tools between turns, which moves
|
|
8505
|
-
// the "last tool" and invalidates the cached prefix after it. When the
|
|
8506
|
-
// detector reports the tool boundary is thrashing while the system prompt
|
|
8507
|
-
// is stable, we SKIP the volatile last-tool breakpoint and let the stable
|
|
8508
|
-
// system-prompt breakpoint (added by buildSystemPromptBlocks) anchor the
|
|
8509
|
-
// cache. Falls back to exact CC behavior when no stability data exists.
|
|
8510
|
-
const placeToolBreakpoint = shouldPlaceToolBreakpoint(runtime?.cacheBoundaryStability);
|
|
8511
|
-
if (placeToolBreakpoint) {
|
|
8512
|
-
parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
|
|
8513
|
-
}
|
|
8514
|
-
}
|
|
8515
|
-
if (Array.isArray(parsed.messages)) {
|
|
8516
|
-
for (const msg of parsed.messages) {
|
|
8517
|
-
if (Array.isArray(msg.content)) {
|
|
8518
|
-
for (const block of msg.content) {
|
|
8519
|
-
// CONTRACT GUARD: `thinking` / `redacted_thinking` blocks MUST be
|
|
8520
|
-
// round-tripped byte-identical to the model's original response
|
|
8521
|
-
// (Anthropic extended-thinking rules). ANY mutation — including
|
|
8522
|
-
// `delete block.cache_control` — triggers the 400 error
|
|
8523
|
-
// "thinking or redacted_thinking blocks in the latest assistant
|
|
8524
|
-
// message cannot be modified". `cache_control` is not even a valid
|
|
8525
|
-
// field on a thinking block, so we never strip nor add it here;
|
|
8526
|
-
// we leave the block exactly as received. This is required for
|
|
8527
|
-
// adaptive-thinking models (Opus 4.6/4.7/4.8, Sonnet 4.6) on
|
|
8528
|
-
// tool-continuation turns. See docs/mimese-http-header-system-prompt.md.
|
|
8529
|
-
if (!block || typeof block !== "object") continue;
|
|
8530
|
-
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
8531
|
-
continue;
|
|
8532
|
-
}
|
|
8533
|
-
if (block.cache_control) delete block.cache_control;
|
|
8534
|
-
}
|
|
8535
|
-
}
|
|
8536
|
-
}
|
|
8537
|
-
// Add cache_control to last user message (real CC does this).
|
|
8538
|
-
// (User messages never contain thinking blocks, so the guard above
|
|
8539
|
-
// does not affect this breakpoint.)
|
|
8540
|
-
for (let i = parsed.messages.length - 1; i >= 0; i--) {
|
|
8541
|
-
const msg = parsed.messages[i];
|
|
8542
|
-
if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
|
|
8543
|
-
const lastBlock = msg.content[msg.content.length - 1];
|
|
8544
|
-
if (lastBlock && typeof lastBlock === "object") {
|
|
8545
|
-
lastBlock.cache_control = { type: "ephemeral", ttl: ccTtl };
|
|
8546
|
-
}
|
|
8547
|
-
break;
|
|
8548
|
-
}
|
|
8549
|
-
}
|
|
8550
|
-
}
|
|
8551
|
-
|
|
8552
|
-
// Tool name sanitization: Anthropic's server blocklists known non-CC tool names.
|
|
8553
|
-
// opencode uses lowercase names while CC uses PascalCase. While only "todowrite"
|
|
8554
|
-
// is currently confirmed blocklisted, we rename ALL core opencode tools to match
|
|
8555
|
-
// CC's naming convention as a preventive measure against future blocklist additions.
|
|
8556
|
-
const OC_TO_CC_TOOL_NAMES = {
|
|
8557
|
-
bash: "Bash",
|
|
8558
|
-
read: "Read",
|
|
8559
|
-
glob: "Glob",
|
|
8560
|
-
grep: "Grep",
|
|
8561
|
-
edit: "Edit",
|
|
8562
|
-
write: "Write",
|
|
8563
|
-
webfetch: "WebFetch",
|
|
8564
|
-
todowrite: "TodoWrite",
|
|
8565
|
-
skill: "Skill",
|
|
8566
|
-
task: "Task",
|
|
8567
|
-
compress: "Compress",
|
|
8568
|
-
};
|
|
8569
|
-
if (Array.isArray(parsed.tools)) {
|
|
8570
|
-
for (const tool of parsed.tools) {
|
|
8571
|
-
if (tool.name && OC_TO_CC_TOOL_NAMES[tool.name]) {
|
|
8572
|
-
tool.name = OC_TO_CC_TOOL_NAMES[tool.name];
|
|
8573
|
-
}
|
|
8574
|
-
}
|
|
8575
|
-
}
|
|
8576
|
-
// Also rename in tool_use blocks in messages (assistant responses referencing the tool)
|
|
8577
|
-
if (Array.isArray(parsed.messages)) {
|
|
8578
|
-
for (const msg of parsed.messages) {
|
|
8579
|
-
if (!Array.isArray(msg.content)) continue;
|
|
8580
|
-
for (const block of msg.content) {
|
|
8581
|
-
if (block.type === "tool_use" && block.name && OC_TO_CC_TOOL_NAMES[block.name]) {
|
|
8582
|
-
block.name = OC_TO_CC_TOOL_NAMES[block.name];
|
|
8583
|
-
}
|
|
8584
|
-
}
|
|
8585
|
-
}
|
|
8586
|
-
}
|
|
8587
|
-
// Track which tools the model has used (from assistant tool_use blocks).
|
|
8588
|
-
// Names are already CC PascalCase after renaming above.
|
|
8589
|
-
if (Array.isArray(parsed.messages)) {
|
|
8590
|
-
for (const msg of parsed.messages) {
|
|
8591
|
-
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
8592
|
-
for (const block of msg.content) {
|
|
8593
|
-
if (block.type === "tool_use" && block.name) {
|
|
8594
|
-
runtime.usedTools.add(block.name);
|
|
8595
|
-
}
|
|
8596
|
-
}
|
|
8597
|
-
}
|
|
8598
|
-
}
|
|
8599
|
-
}
|
|
8600
|
-
|
|
8601
|
-
// Strategy 4 — Adaptive tool set: after turn 3, defer non-core tools that
|
|
8602
|
-
// the model hasn't used yet. This saves schema bytes on turns where tools
|
|
8603
|
-
// are unlikely to be needed. Core tools stay eager. Resets on /clear.
|
|
8604
|
-
if (
|
|
8605
|
-
Array.isArray(parsed.tools) &&
|
|
8606
|
-
signature.adaptiveToolSet !== false &&
|
|
8607
|
-
runtime.turns >= 3 &&
|
|
8608
|
-
parsed.model &&
|
|
8609
|
-
!/claude-3-|haiku/i.test(parsed.model)
|
|
8610
|
-
) {
|
|
8611
|
-
const used = runtime.usedTools;
|
|
8612
|
-
for (const tool of parsed.tools) {
|
|
8613
|
-
if (tool.name && !used.has(tool.name) && !CORE_TOOL_NAMES.has(tool.name)) {
|
|
8614
|
-
tool.defer_loading = true;
|
|
8615
|
-
}
|
|
8616
|
-
}
|
|
8617
|
-
}
|
|
8618
|
-
|
|
8619
|
-
// Tool description compaction: apply the same compaction logic used for
|
|
8620
|
-
// system prompts (strip examples, collapse whitespace, dedup lines) to tool
|
|
8621
|
-
// descriptions. The top 4 tools (Bash 10.6K, TodoWrite 9.7K, Task 5.5K,
|
|
8622
|
-
// Compress 4.5K) account for 30KB. Compaction typically saves 30-50%.
|
|
8623
|
-
if (Array.isArray(parsed.tools) && signature.toolDescriptionCompaction !== false) {
|
|
8624
|
-
for (const tool of parsed.tools) {
|
|
8625
|
-
if (tool.description && tool.description.length > 500) {
|
|
8626
|
-
tool.description = compactToolDescription(tool.description);
|
|
8627
|
-
}
|
|
8628
|
-
}
|
|
8629
|
-
}
|
|
8630
|
-
|
|
8631
|
-
// MCP tool deferral: mark non-core tools with defer_loading: true.
|
|
8632
|
-
// CC defers all MCP tools by default — the API omits their full schemas from token
|
|
8633
|
-
// counting, only sending the tool name. When the model needs a deferred tool, it uses
|
|
8634
|
-
// tool_reference to load the schema on demand. This saves ~20KB per turn.
|
|
8635
|
-
// Core tools (OC_TO_CC_TOOL_NAMES) are always eager-loaded.
|
|
8636
|
-
if (
|
|
8637
|
-
Array.isArray(parsed.tools) &&
|
|
8638
|
-
signature.toolDeferral !== false &&
|
|
8639
|
-
parsed.model &&
|
|
8640
|
-
!/claude-3-|haiku/i.test(parsed.model)
|
|
8641
|
-
) {
|
|
8642
|
-
const coreToolNames = new Set(Object.values(OC_TO_CC_TOOL_NAMES));
|
|
8643
|
-
for (const tool of parsed.tools) {
|
|
8644
|
-
if (tool.name && !coreToolNames.has(tool.name)) {
|
|
8645
|
-
tool.defer_loading = true;
|
|
8646
|
-
}
|
|
8647
|
-
}
|
|
8648
|
-
}
|
|
8649
5793
|
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
5794
|
+
/**
|
|
5795
|
+
* @param {string} incomingBeta
|
|
5796
|
+
* @param {boolean} signatureEnabled
|
|
5797
|
+
* @param {string} model
|
|
5798
|
+
* @param {"anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle"} provider
|
|
5799
|
+
* @param {string[]} [customBetas]
|
|
5800
|
+
* @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
|
|
5801
|
+
* @param {string} [requestPath]
|
|
5802
|
+
* @param {boolean} [hasFileReferences]
|
|
5803
|
+
* @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
|
|
5804
|
+
* @param {boolean} [fastModeActive] - When true, emits FAST_MODE_BETA_FLAG (fast-mode-2026-02-01).
|
|
5805
|
+
* Must be derived structurally from the already-transformed outgoing body (body.includes('"speed":"fast"')).
|
|
5806
|
+
* Only passed from the buildRequestHeaders call site (after body transform). The pre-transform
|
|
5807
|
+
* computedBetaHeader call site (index.mjs:2905) leaves this undefined so the latch never
|
|
5808
|
+
* accumulates fast-mode independently of actual body content.
|
|
5809
|
+
* @returns {string}
|
|
5810
|
+
*/
|
|
5811
|
+
// Mirrors CC's Kw(model) effort eligibility: returns false for claude-3-* and the
|
|
5812
|
+
// explicit older 4.x exclusion set (opus-4-0/4-1, sonnet-4-0/4-5, haiku-4-5), true
|
|
5813
|
+
// for every other model (effort-capable: Opus 4.5/4.6/4.7/4.8, Sonnet 4.6, etc.).
|
|
8660
5814
|
|
|
8661
|
-
|
|
8662
|
-
// the body field context_hint:{enabled:true}. Extracted from CC bundle
|
|
8663
|
-
// buildRequestParams(): `{betaHeader, body:{context_hint:{enabled:!0}}}`.
|
|
8664
|
-
// When active, the server responds with optional hints suggesting which
|
|
8665
|
-
// messages to drop for token-efficient context compaction.
|
|
8666
|
-
if (betaHeader && betaHeader.includes("context-hint-2026-04-09") && !parsed.context_hint) {
|
|
8667
|
-
parsed.context_hint = { enabled: true };
|
|
8668
|
-
}
|
|
5815
|
+
/** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
|
|
8669
5816
|
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
// supported on Opus 4.6, Opus 4.7, and Opus 4.8 (research preview). Opus 4.7
|
|
8673
|
-
// is the /fast default in real Claude Code v2.1.142+. Sonnet is NOT eligible.
|
|
8674
|
-
// NOTE: switching speed invalidates system + message prompt caches (per
|
|
8675
|
-
// Anthropic fast-mode docs), so only flip it deliberately.
|
|
8676
|
-
// When the selected account's fast pool is cooling down (a prior fast 429),
|
|
8677
|
-
// suppress speed:"fast" entirely so this turn runs at standard speed on the
|
|
8678
|
-
// same account rather than re-hitting the exhausted fast pool.
|
|
8679
|
-
const fastPoolAvailable = !signature.fastRateLimited;
|
|
8680
|
-
const isFastModeEligibleModel = (m) =>
|
|
8681
|
-
fastPoolAvailable && (isOpus46Model(m) || isOpus47Model(m) || isOpus48Model(m));
|
|
8682
|
-
const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
|
|
8683
|
-
let fastModeAutoApplied = false;
|
|
8684
|
-
if (
|
|
8685
|
-
!fastModeEnabled &&
|
|
8686
|
-
te.fast_mode_auto === true &&
|
|
8687
|
-
isMainRole &&
|
|
8688
|
-
parsed.model &&
|
|
8689
|
-
isFastModeEligibleModel(parsed.model) &&
|
|
8690
|
-
Array.isArray(parsed.messages) &&
|
|
8691
|
-
parsed.messages.length >= 2
|
|
8692
|
-
) {
|
|
8693
|
-
// Simple exchange heuristic: last user message is short, no tool_result,
|
|
8694
|
-
// no file references. Suggests a follow-up question that doesn't need
|
|
8695
|
-
// deep reasoning.
|
|
8696
|
-
const last = parsed.messages[parsed.messages.length - 1];
|
|
8697
|
-
if (last && last.role === "user") {
|
|
8698
|
-
let txt = "";
|
|
8699
|
-
let hasToolResult = false;
|
|
8700
|
-
if (typeof last.content === "string") txt = last.content;
|
|
8701
|
-
else if (Array.isArray(last.content)) {
|
|
8702
|
-
for (const b of last.content) {
|
|
8703
|
-
if (b?.type === "tool_result") hasToolResult = true;
|
|
8704
|
-
if (b?.type === "text" && typeof b.text === "string") txt += b.text;
|
|
8705
|
-
}
|
|
8706
|
-
}
|
|
8707
|
-
if (!hasToolResult && txt.length < 240 && !/\bfile:|\.md\b|\.mjs\b|\.ts\b/i.test(txt)) {
|
|
8708
|
-
fastModeAutoApplied = true;
|
|
8709
|
-
}
|
|
8710
|
-
}
|
|
8711
|
-
}
|
|
8712
|
-
if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isFastModeEligibleModel(parsed.model)) {
|
|
8713
|
-
parsed.speed = "fast";
|
|
8714
|
-
}
|
|
5817
|
+
// QA fix L3: budgetTokensToEffort() removed — dead code, never called
|
|
5818
|
+
// QA fix L4: isValidEffort() removed — dead code, never called
|
|
8715
5819
|
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
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
|
-
}
|
|
5820
|
+
/**
|
|
5821
|
+
* Normalise the `thinking` block in the request body for the target model:
|
|
5822
|
+
* - Opus 4.6 / 4.7 / 4.8 / Sonnet 4.6 (adaptive thinking): produces
|
|
5823
|
+
* `{ type: "adaptive" }`. This is REQUIRED for Opus 4.7/4.8 — manual
|
|
5824
|
+
* `{ type: "enabled", budget_tokens }` returns a 400 on those models.
|
|
5825
|
+
* - Older models: passes the existing thinking block through unchanged.
|
|
5826
|
+
*
|
|
5827
|
+
* @param {any} thinking
|
|
5828
|
+
* @param {string} model
|
|
5829
|
+
* @returns {any}
|
|
5830
|
+
*/
|
|
5831
|
+
/**
|
|
5832
|
+
* Map Node.js platform to Stainless OS header value.
|
|
5833
|
+
* @param {NodeJS.Platform} value
|
|
5834
|
+
* @returns {string}
|
|
5835
|
+
*/
|
|
8749
5836
|
|
|
8750
|
-
|
|
5837
|
+
/**
|
|
5838
|
+
* Normalize Node.js arch to Stainless arch header value.
|
|
5839
|
+
* @param {string} value
|
|
5840
|
+
* @returns {string}
|
|
5841
|
+
*/
|
|
5842
|
+
|
|
5843
|
+
/**
|
|
5844
|
+
* Resolve latest claude-code package version from npm registry.
|
|
5845
|
+
* Returns null on timeout/network/parse failures.
|
|
5846
|
+
* @param {number} timeoutMs
|
|
5847
|
+
* @returns {Promise<string | null>}
|
|
5848
|
+
*/
|
|
5849
|
+
async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
|
|
5850
|
+
const controller = new AbortController();
|
|
5851
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
5852
|
+
try {
|
|
5853
|
+
const response = await fetch(CLAUDE_CODE_NPM_LATEST_URL, {
|
|
5854
|
+
method: "GET",
|
|
5855
|
+
headers: { accept: "application/json" },
|
|
5856
|
+
signal: controller.signal,
|
|
5857
|
+
});
|
|
5858
|
+
if (!response.ok) return null;
|
|
5859
|
+
const data = await response.json();
|
|
5860
|
+
if (!data || typeof data !== "object") return null;
|
|
5861
|
+
return typeof data.version === "string" && data.version ? data.version : null;
|
|
8751
5862
|
} catch {
|
|
8752
|
-
|
|
8753
|
-
|
|
5863
|
+
return null;
|
|
5864
|
+
} finally {
|
|
5865
|
+
clearTimeout(timeout);
|
|
8754
5866
|
}
|
|
8755
5867
|
}
|
|
8756
5868
|
|
|
5869
|
+
/**
|
|
5870
|
+
* Build request headers from input and init, applying OAuth requirements.
|
|
5871
|
+
* Preserves behaviors D1-D7.
|
|
5872
|
+
*
|
|
5873
|
+
* @param {any} input
|
|
5874
|
+
* @param {Record<string, any>} requestInit
|
|
5875
|
+
* @param {string} accessToken
|
|
5876
|
+
* @param {string | undefined} requestBody
|
|
5877
|
+
* @param {URL | null} requestUrl
|
|
5878
|
+
* @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[], sessionId?: string}} signature
|
|
5879
|
+
* @returns {Headers}
|
|
5880
|
+
*/
|
|
5881
|
+
|
|
5882
|
+
/**
|
|
5883
|
+
* Resolve max_tokens for a request based on output cap configuration.
|
|
5884
|
+
* If the caller specified max_tokens, it is preserved. Otherwise, defaults
|
|
5885
|
+
* to 8K and escalates to 64K after an output truncation (stop_reason: "max_tokens").
|
|
5886
|
+
*
|
|
5887
|
+
* @param {Record<string, any>} body - Parsed request body
|
|
5888
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} config
|
|
5889
|
+
* @returns {number | undefined} Resolved max_tokens value, or undefined for passthrough
|
|
5890
|
+
*/
|
|
5891
|
+
|
|
8757
5892
|
/**
|
|
8758
5893
|
* Transform the request URL: add ?beta=true to /v1/messages and /v1/messages/count_tokens.
|
|
8759
5894
|
* Preserves behaviors F1-F3.
|
|
@@ -8815,234 +5950,9 @@ function transformRequestUrl(input) {
|
|
|
8815
5950
|
* @property {number} cacheReadTokens
|
|
8816
5951
|
* @property {number} cacheWriteTokens
|
|
8817
5952
|
* @property {number} [webSearchRequests]
|
|
5953
|
+
* @property {string} [lastStopReason]
|
|
8818
5954
|
*/
|
|
8819
5955
|
|
|
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
5956
|
/**
|
|
9047
5957
|
* Wrap a response body stream to strip mcp_ prefix from tool names,
|
|
9048
5958
|
* extract token usage stats from SSE events, and detect mid-stream
|
|
@@ -9053,166 +5963,20 @@ function resolveStreamIdleTimeoutMs(cfg) {
|
|
|
9053
5963
|
* @param {((stats: UsageStats) => void) | null} [onUsage] - Called when stream ends with final usage
|
|
9054
5964
|
* @param {((details: {reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [onAccountError]
|
|
9055
5965
|
* Called if a mid-stream error looks account-specific
|
|
5966
|
+
* @param {string} [correlationId]
|
|
9056
5967
|
* @returns {Response}
|
|
9057
5968
|
*/
|
|
9058
|
-
function transformResponse(response, onUsage, onAccountError) {
|
|
5969
|
+
function transformResponse(response, onUsage, onAccountError, correlationId) {
|
|
9059
5970
|
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
5971
|
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
|
-
},
|
|
5972
|
+
const captureEnabled = _pluginConfig?.token_economy?.debug_dump_bodies === true && Boolean(correlationId);
|
|
5973
|
+
const stream = createTransformedSSEStream(response, {
|
|
5974
|
+
onUsage,
|
|
5975
|
+
onAccountError,
|
|
5976
|
+
correlationId,
|
|
5977
|
+
idleTimeoutMs,
|
|
5978
|
+
captureEnabled,
|
|
5979
|
+
writeSseCapture,
|
|
9216
5980
|
});
|
|
9217
5981
|
|
|
9218
5982
|
// Inject cache transparency headers (session-level, available before stream completes).
|
|
@@ -9240,19 +6004,6 @@ function isEventStreamResponse(response) {
|
|
|
9240
6004
|
return contentType.toLowerCase().includes("text/event-stream");
|
|
9241
6005
|
}
|
|
9242
6006
|
|
|
9243
|
-
/**
|
|
9244
|
-
* Build user-facing switch reason text for account-specific errors.
|
|
9245
|
-
* @param {number} status
|
|
9246
|
-
* @param {import('./lib/backoff.mjs').RateLimitReason} reason
|
|
9247
|
-
* @returns {string}
|
|
9248
|
-
*/
|
|
9249
|
-
function formatSwitchReason(status, reason) {
|
|
9250
|
-
if (reason === "AUTH_FAILED") return "auth failed";
|
|
9251
|
-
if (status === 403 && reason === "QUOTA_EXHAUSTED") return "permission denied";
|
|
9252
|
-
if (reason === "QUOTA_EXHAUSTED") return "quota exhausted";
|
|
9253
|
-
return "rate-limited";
|
|
9254
|
-
}
|
|
9255
|
-
|
|
9256
6007
|
// ---------------------------------------------------------------------------
|
|
9257
6008
|
// Token refresh (per-account)
|
|
9258
6009
|
// ---------------------------------------------------------------------------
|
|
@@ -9325,7 +6076,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
|
|
|
9325
6076
|
* @returns {Promise<string>} The new access token
|
|
9326
6077
|
* @throws {Error} If refresh fails
|
|
9327
6078
|
*/
|
|
9328
|
-
async function refreshAccountToken(account, client,
|
|
6079
|
+
async function refreshAccountToken(account, client, _source = "foreground", { onTokensUpdated } = {}) {
|
|
9329
6080
|
// CC-sourced accounts must NEVER enter the OAuth HTTP refresh flow.
|
|
9330
6081
|
// Instead, re-read credentials from the CC source. If they're still
|
|
9331
6082
|
// expired, let the caller handle it (the account will be skipped).
|
|
@@ -9485,26 +6236,6 @@ function parseCommandArgs(raw) {
|
|
|
9485
6236
|
* @param {any} body - Parsed JSON body
|
|
9486
6237
|
* @returns {string[]}
|
|
9487
6238
|
*/
|
|
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
6239
|
|
|
9509
6240
|
// Internals exposed for tests only. Do not consume from production code paths.
|
|
9510
6241
|
//
|
|
@@ -9539,10 +6270,10 @@ AnthropicAuthPlugin.__testing__ = {
|
|
|
9539
6270
|
// exposed for experimental.session.summarize integration tests
|
|
9540
6271
|
runHaikuSessionSummarize,
|
|
9541
6272
|
get cachedCCPrompt() {
|
|
9542
|
-
return
|
|
6273
|
+
return getCachedCCPrompt();
|
|
9543
6274
|
},
|
|
9544
6275
|
resetCachedCCPrompt() {
|
|
9545
|
-
|
|
6276
|
+
resetCachedCCPrompt();
|
|
9546
6277
|
},
|
|
9547
6278
|
SUBAGENT_CC_ANCHOR,
|
|
9548
6279
|
CLAUDE_CODE_IDENTITY_STRING,
|