@bubblebrain-ai/bubble 0.0.44 → 0.0.46
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/dist/agent.js +7 -3
- package/dist/context/prune.d.ts +17 -1
- package/dist/context/prune.js +32 -1
- package/dist/model-pricing.d.ts +7 -0
- package/dist/model-pricing.js +54 -2
- package/dist/oauth/grok.js +53 -27
- package/dist/oauth/openai-codex.js +13 -0
- package/dist/print-output.d.ts +14 -0
- package/dist/print-output.js +12 -2
- package/dist/provider-anthropic.d.ts +26 -0
- package/dist/provider-anthropic.js +121 -2
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -13,7 +13,7 @@ import { buildContextUsageSnapshot } from "./context/usage.js";
|
|
|
13
13
|
import { isContextOverflowError } from "./context/overflow.js";
|
|
14
14
|
import { computeRetryDelayMs, isProviderStreamInterruption, MAX_STREAM_INTERRUPTION_RETRIES, sleepBeforeRetry, } from "./network/retry.js";
|
|
15
15
|
import { projectMessages } from "./context/projector.js";
|
|
16
|
-
import { aggressivePruneMessages, markStableCurrentToolResultsForCache } from "./context/prune.js";
|
|
16
|
+
import { aggressivePruneMessages, markStableCurrentToolResultsForCache, markToolResultCacheStable } from "./context/prune.js";
|
|
17
17
|
import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
|
|
18
18
|
import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode } from "./prompt/reminders.js";
|
|
19
19
|
import { HookBus } from "./orchestrator/hooks.js";
|
|
@@ -1312,13 +1312,17 @@ export class Agent {
|
|
|
1312
1312
|
limit: truncatedOutput.limit,
|
|
1313
1313
|
},
|
|
1314
1314
|
}, traceContext);
|
|
1315
|
-
|
|
1315
|
+
const toolMessage = {
|
|
1316
1316
|
role: "tool",
|
|
1317
1317
|
toolCallId: tc.id,
|
|
1318
1318
|
content: truncatedOutput.content,
|
|
1319
1319
|
metadata: result.metadata,
|
|
1320
1320
|
isError: result.isError,
|
|
1321
|
-
}
|
|
1321
|
+
};
|
|
1322
|
+
// Stamp before appending: appendMessage persists to the session log
|
|
1323
|
+
// synchronously, so a mark added on a later turn never reaches disk.
|
|
1324
|
+
markToolResultCacheStable(tc.name, toolMessage);
|
|
1325
|
+
this.appendMessage(toolMessage);
|
|
1322
1326
|
this.compactResidentHistory();
|
|
1323
1327
|
flushGovernorReminders();
|
|
1324
1328
|
this.onToolResult?.(tc.name, result);
|
package/dist/context/prune.d.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
import type { Message } from "../types.js";
|
|
1
|
+
import type { Message, ToolMessage } from "../types.js";
|
|
2
2
|
export declare function pruneMessages<T extends Message>(messages: T[]): T[];
|
|
3
|
+
/**
|
|
4
|
+
* Stamp a tool result as "projected in full" at the moment it is appended.
|
|
5
|
+
*
|
|
6
|
+
* markStableCurrentToolResultsForCache does the same thing one turn later, which
|
|
7
|
+
* is too late for the session log: the message is serialized to disk when it is
|
|
8
|
+
* appended, so a mark added afterwards never reaches the file and a resumed
|
|
9
|
+
* session re-prunes history the model had already been shown in full.
|
|
10
|
+
*/
|
|
11
|
+
export declare function markToolResultCacheStable(toolName: string, message: ToolMessage): void;
|
|
3
12
|
export declare function markStableCurrentToolResultsForCache(messages: Message[]): void;
|
|
4
13
|
/**
|
|
5
14
|
* Aggressive variant of pruneMessages: drops the content of every prunable
|
|
6
15
|
* tool output except the latest unresolved tool turn that the model still
|
|
7
16
|
* needs to reason over. Used as a last-resort microcompact pass when a
|
|
8
17
|
* standard prune hasn't reclaimed enough budget.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately ignores `cacheStableProjection`, unlike pruneMessages. Honouring
|
|
20
|
+
* it here would make this a no-op — every result that has been sent once is
|
|
21
|
+
* marked — which is fine for the prefix cache and fatal for the two callers that
|
|
22
|
+
* depend on it: context-overflow (HTTP 400) recovery and the hard resident-memory
|
|
23
|
+
* limit. Losing the cache is the correct trade when the alternative is a run that
|
|
24
|
+
* cannot recover.
|
|
9
25
|
*/
|
|
10
26
|
export declare function aggressivePruneMessages<T extends Message>(messages: T[]): T[];
|
package/dist/context/prune.js
CHANGED
|
@@ -54,6 +54,22 @@ export function pruneMessages(messages) {
|
|
|
54
54
|
};
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Stamp a tool result as "projected in full" at the moment it is appended.
|
|
59
|
+
*
|
|
60
|
+
* markStableCurrentToolResultsForCache does the same thing one turn later, which
|
|
61
|
+
* is too late for the session log: the message is serialized to disk when it is
|
|
62
|
+
* appended, so a mark added afterwards never reaches the file and a resumed
|
|
63
|
+
* session re-prunes history the model had already been shown in full.
|
|
64
|
+
*/
|
|
65
|
+
export function markToolResultCacheStable(toolName, message) {
|
|
66
|
+
if (!shouldPruneToolResult(toolName, message.content))
|
|
67
|
+
return;
|
|
68
|
+
message.metadata = {
|
|
69
|
+
...message.metadata,
|
|
70
|
+
[CACHE_STABLE_PROJECTION_KEY]: CACHE_STABLE_FULL_PROJECTION,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
57
73
|
export function markStableCurrentToolResultsForCache(messages) {
|
|
58
74
|
const protectedToolCallIds = collectProtectedToolCallIds(messages);
|
|
59
75
|
if (protectedToolCallIds.size === 0)
|
|
@@ -101,6 +117,13 @@ function isCacheStableFullToolResult(message) {
|
|
|
101
117
|
* tool output except the latest unresolved tool turn that the model still
|
|
102
118
|
* needs to reason over. Used as a last-resort microcompact pass when a
|
|
103
119
|
* standard prune hasn't reclaimed enough budget.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately ignores `cacheStableProjection`, unlike pruneMessages. Honouring
|
|
122
|
+
* it here would make this a no-op — every result that has been sent once is
|
|
123
|
+
* marked — which is fine for the prefix cache and fatal for the two callers that
|
|
124
|
+
* depend on it: context-overflow (HTTP 400) recovery and the hard resident-memory
|
|
125
|
+
* limit. Losing the cache is the correct trade when the alternative is a run that
|
|
126
|
+
* cannot recover.
|
|
104
127
|
*/
|
|
105
128
|
export function aggressivePruneMessages(messages) {
|
|
106
129
|
const toolNameByCallId = new Map();
|
|
@@ -128,7 +151,15 @@ export function aggressivePruneMessages(messages) {
|
|
|
128
151
|
function collectProtectedToolCallIds(messages) {
|
|
129
152
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
130
153
|
const message = messages[index];
|
|
131
|
-
|
|
154
|
+
// A trailing user message is steered input (or a resumed session's new
|
|
155
|
+
// instruction), not the end of the tool batch. Stopping on it left the
|
|
156
|
+
// just-finished batch unmarked forever, so it stayed a live prune candidate
|
|
157
|
+
// and later batches rewrote its content mid-history — which both loses tool
|
|
158
|
+
// output the model may still need and invalidates the provider prefix cache.
|
|
159
|
+
if (message.role === "tool"
|
|
160
|
+
|| message.role === "system"
|
|
161
|
+
|| message.role === "meta"
|
|
162
|
+
|| message.role === "user") {
|
|
132
163
|
continue;
|
|
133
164
|
}
|
|
134
165
|
if (message.role === "assistant" && message.toolCalls && message.toolCalls.length > 0) {
|
package/dist/model-pricing.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export interface ModelPricing {
|
|
|
6
6
|
currency: PricingCurrency;
|
|
7
7
|
inputCacheHitPerMillion: number;
|
|
8
8
|
inputCacheMissPerMillion: number;
|
|
9
|
+
/**
|
|
10
|
+
* Rate for tokens written INTO the cache, when the provider charges a premium
|
|
11
|
+
* for it (Anthropic: 1.25x base input for the 5-minute TTL). Optional —
|
|
12
|
+
* without it, cache writes are billed at the plain miss rate, which is what
|
|
13
|
+
* every provider modelled here did before Anthropic caching was enabled.
|
|
14
|
+
*/
|
|
15
|
+
inputCacheWritePerMillion?: number;
|
|
9
16
|
outputPerMillion: number;
|
|
10
17
|
effectiveUntil?: string;
|
|
11
18
|
original?: {
|
package/dist/model-pricing.js
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
export const MODEL_PRICING = [
|
|
2
|
+
// Anthropic cache multipliers off base input: read 0.1x, 5-minute write 1.25x.
|
|
2
3
|
{
|
|
3
4
|
providerId: "anthropic",
|
|
4
5
|
modelId: "claude-fable-5",
|
|
5
6
|
currency: "USD",
|
|
6
|
-
inputCacheHitPerMillion:
|
|
7
|
+
inputCacheHitPerMillion: 1,
|
|
7
8
|
inputCacheMissPerMillion: 10,
|
|
9
|
+
inputCacheWritePerMillion: 12.5,
|
|
8
10
|
outputPerMillion: 50,
|
|
9
11
|
},
|
|
12
|
+
{
|
|
13
|
+
providerId: "anthropic",
|
|
14
|
+
modelId: "claude-opus-4-8",
|
|
15
|
+
currency: "USD",
|
|
16
|
+
inputCacheHitPerMillion: 0.5,
|
|
17
|
+
inputCacheMissPerMillion: 5,
|
|
18
|
+
inputCacheWritePerMillion: 6.25,
|
|
19
|
+
outputPerMillion: 25,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
providerId: "anthropic",
|
|
23
|
+
modelId: "claude-sonnet-5",
|
|
24
|
+
currency: "USD",
|
|
25
|
+
inputCacheHitPerMillion: 0.2,
|
|
26
|
+
inputCacheMissPerMillion: 2,
|
|
27
|
+
inputCacheWritePerMillion: 2.5,
|
|
28
|
+
outputPerMillion: 10,
|
|
29
|
+
effectiveUntil: "2026-08-31T23:59:00Z",
|
|
30
|
+
original: {
|
|
31
|
+
inputCacheHitPerMillion: 0.3,
|
|
32
|
+
inputCacheMissPerMillion: 3,
|
|
33
|
+
outputPerMillion: 15,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
providerId: "anthropic",
|
|
38
|
+
modelId: "claude-sonnet-4-6",
|
|
39
|
+
currency: "USD",
|
|
40
|
+
inputCacheHitPerMillion: 0.3,
|
|
41
|
+
inputCacheMissPerMillion: 3,
|
|
42
|
+
inputCacheWritePerMillion: 3.75,
|
|
43
|
+
outputPerMillion: 15,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
providerId: "anthropic",
|
|
47
|
+
modelId: "claude-haiku-4-5",
|
|
48
|
+
currency: "USD",
|
|
49
|
+
inputCacheHitPerMillion: 0.1,
|
|
50
|
+
inputCacheMissPerMillion: 1,
|
|
51
|
+
inputCacheWritePerMillion: 1.25,
|
|
52
|
+
outputPerMillion: 5,
|
|
53
|
+
},
|
|
10
54
|
{
|
|
11
55
|
providerId: "deepseek",
|
|
12
56
|
modelId: "deepseek-v4-flash",
|
|
@@ -52,8 +96,16 @@ export function calculateUsageCost(providerId, modelId, usage) {
|
|
|
52
96
|
const miss = hasCacheBreakdown
|
|
53
97
|
? usage.promptCacheMissTokens ?? Math.max(0, usage.promptTokens - hit)
|
|
54
98
|
: usage.promptTokens;
|
|
99
|
+
// `miss` already contains the cache-write tokens (see mergeAnthropicUsage),
|
|
100
|
+
// so writes must be netted out before being re-priced — adding a write term
|
|
101
|
+
// on top of the untouched miss term would bill them at miss + write.
|
|
102
|
+
const write = pricing.inputCacheWritePerMillion !== undefined
|
|
103
|
+
? Math.min(usage.cacheCreationTokens ?? 0, miss)
|
|
104
|
+
: 0;
|
|
105
|
+
const missOnly = miss - write;
|
|
55
106
|
const cost = (hit / 1_000_000) * pricing.inputCacheHitPerMillion
|
|
56
|
-
+ (
|
|
107
|
+
+ (write / 1_000_000) * (pricing.inputCacheWritePerMillion ?? pricing.inputCacheMissPerMillion)
|
|
108
|
+
+ (missOnly / 1_000_000) * pricing.inputCacheMissPerMillion
|
|
57
109
|
+ (usage.completionTokens / 1_000_000) * pricing.outputPerMillion;
|
|
58
110
|
return {
|
|
59
111
|
currency: pricing.currency,
|
package/dist/oauth/grok.js
CHANGED
|
@@ -34,11 +34,28 @@ function openBrowser(url) {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* xAI's OAuth client allows dynamic loopback ports, so listen on an ephemeral
|
|
37
|
-
* port
|
|
37
|
+
* port. Awaiting this returns only once the socket is actually bound, so the
|
|
38
|
+
* caller can never build an authorization URL around an empty redirect URI.
|
|
38
39
|
*/
|
|
39
|
-
async function startCallbackServer(timeoutMs,
|
|
40
|
-
|
|
40
|
+
async function startCallbackServer(timeoutMs, onStatus) {
|
|
41
|
+
let settle = () => { };
|
|
42
|
+
let fail = () => { };
|
|
43
|
+
const result = new Promise((resolve, reject) => {
|
|
44
|
+
settle = resolve;
|
|
45
|
+
fail = reject;
|
|
46
|
+
});
|
|
47
|
+
// Nothing awaits `result` until the caller has a redirect URI, and an early
|
|
48
|
+
// rejection (bind failure) would otherwise be an unhandled rejection.
|
|
49
|
+
result.catch(() => { });
|
|
50
|
+
return new Promise((resolveListening, rejectListening) => {
|
|
41
51
|
let resolved = false;
|
|
52
|
+
const finish = (action) => {
|
|
53
|
+
if (resolved)
|
|
54
|
+
return;
|
|
55
|
+
resolved = true;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
server.close(action);
|
|
58
|
+
};
|
|
42
59
|
const server = createServer((req, res) => {
|
|
43
60
|
try {
|
|
44
61
|
const port = server.address().port;
|
|
@@ -51,19 +68,13 @@ async function startCallbackServer(timeoutMs, onListening, onStatus) {
|
|
|
51
68
|
if (error) {
|
|
52
69
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
53
70
|
res.end(`<html><body><h1>Authorization failed</h1><p>${errorDesc || error}</p></body></html>`);
|
|
54
|
-
|
|
55
|
-
resolved = true;
|
|
56
|
-
server.close(() => reject(new Error(`OAuth error: ${errorDesc || error}`)));
|
|
57
|
-
}
|
|
71
|
+
finish(() => fail(new Error(`OAuth error: ${errorDesc || error}`)));
|
|
58
72
|
return;
|
|
59
73
|
}
|
|
60
74
|
if (code) {
|
|
61
75
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
62
76
|
res.end("<html><body><h1>Authorization successful</h1><p>You can close this window and return to the terminal.</p></body></html>");
|
|
63
|
-
|
|
64
|
-
resolved = true;
|
|
65
|
-
server.close(() => resolve({ code, state: state || "" }));
|
|
66
|
-
}
|
|
77
|
+
finish(() => settle({ code, state: state || "" }));
|
|
67
78
|
return;
|
|
68
79
|
}
|
|
69
80
|
}
|
|
@@ -75,17 +86,32 @@ async function startCallbackServer(timeoutMs, onListening, onStatus) {
|
|
|
75
86
|
res.end("Error");
|
|
76
87
|
}
|
|
77
88
|
});
|
|
89
|
+
// Unref'd so a pending login never keeps the process alive on its own,
|
|
90
|
+
// and cleared on completion so a successful login does not leave the
|
|
91
|
+
// event loop holding a five-minute timer.
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
finish(() => fail(new Error(`OAuth login timed out after ${timeoutMs / 1000}s`)));
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
// Without this, a failed bind (port exhausted, sandbox denies listening)
|
|
97
|
+
// never rejects anything — the login just hangs until the timeout.
|
|
98
|
+
server.on("error", (error) => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
if (resolved)
|
|
101
|
+
return;
|
|
102
|
+
resolved = true;
|
|
103
|
+
rejectListening(error instanceof Error ? error : new Error(String(error)));
|
|
104
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
105
|
+
});
|
|
78
106
|
server.listen(0, "127.0.0.1", () => {
|
|
79
107
|
const port = server.address().port;
|
|
80
108
|
onStatus?.(`Local server listening on http://127.0.0.1:${port}`);
|
|
81
|
-
|
|
109
|
+
// Resolve only once the socket is genuinely bound. The caller used to
|
|
110
|
+
// race this with a single setImmediate tick and declare failure if the
|
|
111
|
+
// listen callback had not fired yet — which it frequently has not, since
|
|
112
|
+
// binding is real async I/O.
|
|
113
|
+
resolveListening({ redirectUri: `http://127.0.0.1:${port}/callback`, result });
|
|
82
114
|
});
|
|
83
|
-
setTimeout(() => {
|
|
84
|
-
if (!resolved) {
|
|
85
|
-
resolved = true;
|
|
86
|
-
server.close(() => reject(new Error(`OAuth login timed out after ${timeoutMs / 1000}s`)));
|
|
87
|
-
}
|
|
88
|
-
}, timeoutMs);
|
|
89
115
|
});
|
|
90
116
|
}
|
|
91
117
|
function parseTokenResponse(data, fallbackRefreshToken) {
|
|
@@ -109,15 +135,15 @@ export async function loginGrok(callbacks, options = {}) {
|
|
|
109
135
|
const pkce = generatePKCE();
|
|
110
136
|
const state = generateOpaqueId();
|
|
111
137
|
const nonce = generateOpaqueId();
|
|
112
|
-
let redirectUri
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
throw new Error(
|
|
138
|
+
let redirectUri;
|
|
139
|
+
let serverPromise;
|
|
140
|
+
try {
|
|
141
|
+
const server = await startCallbackServer(5 * 60 * 1000, callbacks?.onStatus);
|
|
142
|
+
redirectUri = server.redirectUri;
|
|
143
|
+
serverPromise = server.result;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
throw new Error(`The local OAuth callback server could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
147
|
}
|
|
122
148
|
const params = new URLSearchParams({
|
|
123
149
|
response_type: "code",
|
|
@@ -63,6 +63,19 @@ async function startCallbackServer(port, timeoutMs, onStatus) {
|
|
|
63
63
|
res.end("Error");
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
|
+
// This flow binds a FIXED port (the provider only whitelists one redirect
|
|
67
|
+
// URI), so EADDRINUSE is a realistic outcome — a stale login, or any other
|
|
68
|
+
// process on the port. Without this handler nothing rejects and the user
|
|
69
|
+
// stares at "waiting for authorization" for the full timeout.
|
|
70
|
+
server.on("error", (error) => {
|
|
71
|
+
if (resolved)
|
|
72
|
+
return;
|
|
73
|
+
resolved = true;
|
|
74
|
+
const detail = error?.code === "EADDRINUSE"
|
|
75
|
+
? `port ${port} is already in use — close any other sign-in still running and retry`
|
|
76
|
+
: error instanceof Error ? error.message : String(error);
|
|
77
|
+
reject(new Error(`The local OAuth callback server could not start: ${detail}`));
|
|
78
|
+
});
|
|
66
79
|
server.listen(port, "127.0.0.1", () => {
|
|
67
80
|
onStatus?.(`Local server listening on http://127.0.0.1:${port}`);
|
|
68
81
|
});
|
package/dist/print-output.d.ts
CHANGED
|
@@ -10,9 +10,23 @@
|
|
|
10
10
|
import type { AgentEvent } from "./types.js";
|
|
11
11
|
export type PrintOutputFormat = "plain" | "json";
|
|
12
12
|
export declare function parseOutputFormat(raw: string | undefined): PrintOutputFormat | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Anthropic's usage decomposition, which the field names already claimed to
|
|
15
|
+
* follow: the four token buckets are DISJOINT and sum to `total_tokens`.
|
|
16
|
+
*
|
|
17
|
+
* input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
|
18
|
+
* + output_tokens === total_tokens
|
|
19
|
+
*
|
|
20
|
+
* `input_tokens` is therefore the uncached remainder only — it does NOT include
|
|
21
|
+
* cache reads. It used to carry the whole prompt total, which was harmless while
|
|
22
|
+
* cache reads were a flat few thousand tokens per turn, but message-level prompt
|
|
23
|
+
* caching makes reads 70-90% of the prompt, and a harness that priced
|
|
24
|
+
* `input_tokens` at the full input rate would then overstate cost ~2x.
|
|
25
|
+
*/
|
|
13
26
|
export interface PrintUsageSummary {
|
|
14
27
|
input_tokens: number;
|
|
15
28
|
cache_read_input_tokens: number;
|
|
29
|
+
cache_creation_input_tokens: number;
|
|
16
30
|
output_tokens: number;
|
|
17
31
|
reasoning_tokens: number;
|
|
18
32
|
total_tokens: number;
|
package/dist/print-output.js
CHANGED
|
@@ -23,6 +23,7 @@ export class PrintRunCollector {
|
|
|
23
23
|
usage = {
|
|
24
24
|
input_tokens: 0,
|
|
25
25
|
cache_read_input_tokens: 0,
|
|
26
|
+
cache_creation_input_tokens: 0,
|
|
26
27
|
output_tokens: 0,
|
|
27
28
|
reasoning_tokens: 0,
|
|
28
29
|
total_tokens: 0,
|
|
@@ -57,8 +58,17 @@ export class PrintRunCollector {
|
|
|
57
58
|
}
|
|
58
59
|
addUsage(usage) {
|
|
59
60
|
this.usageReported = true;
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const prompt = usage.promptTokens ?? 0;
|
|
62
|
+
const cacheRead = usage.promptCacheHitTokens ?? 0;
|
|
63
|
+
// promptCacheMissTokens already includes creation, so creation has to be
|
|
64
|
+
// netted out of the uncached remainder rather than added alongside it.
|
|
65
|
+
const cacheCreation = usage.cacheCreationTokens ?? 0;
|
|
66
|
+
const uncached = usage.promptCacheMissTokens !== undefined
|
|
67
|
+
? Math.max(0, usage.promptCacheMissTokens - cacheCreation)
|
|
68
|
+
: Math.max(0, prompt - cacheRead - cacheCreation);
|
|
69
|
+
this.usage.input_tokens += uncached;
|
|
70
|
+
this.usage.cache_read_input_tokens += cacheRead;
|
|
71
|
+
this.usage.cache_creation_input_tokens += cacheCreation;
|
|
62
72
|
this.usage.output_tokens += usage.completionTokens ?? 0;
|
|
63
73
|
this.usage.reasoning_tokens += usage.reasoningTokens ?? 0;
|
|
64
74
|
this.usage.total_tokens += usage.totalTokens
|
|
@@ -36,6 +36,7 @@ interface AnthropicSystemBlock {
|
|
|
36
36
|
type AnthropicContentBlock = {
|
|
37
37
|
type: "text";
|
|
38
38
|
text: string;
|
|
39
|
+
cache_control?: AnthropicCacheControl;
|
|
39
40
|
} | {
|
|
40
41
|
type: "image";
|
|
41
42
|
source: {
|
|
@@ -46,6 +47,7 @@ type AnthropicContentBlock = {
|
|
|
46
47
|
media_type: string;
|
|
47
48
|
data: string;
|
|
48
49
|
};
|
|
50
|
+
cache_control?: AnthropicCacheControl;
|
|
49
51
|
} | {
|
|
50
52
|
type: "thinking";
|
|
51
53
|
thinking: string;
|
|
@@ -58,11 +60,13 @@ type AnthropicContentBlock = {
|
|
|
58
60
|
id: string;
|
|
59
61
|
name: string;
|
|
60
62
|
input: Record<string, unknown>;
|
|
63
|
+
cache_control?: AnthropicCacheControl;
|
|
61
64
|
} | {
|
|
62
65
|
type: "tool_result";
|
|
63
66
|
tool_use_id: string;
|
|
64
67
|
content: string;
|
|
65
68
|
is_error?: boolean;
|
|
69
|
+
cache_control?: AnthropicCacheControl;
|
|
66
70
|
};
|
|
67
71
|
interface AnthropicMessage {
|
|
68
72
|
role: "user" | "assistant";
|
|
@@ -83,6 +87,28 @@ export declare function buildAnthropicRequest(options: AnthropicProviderOptions,
|
|
|
83
87
|
thinkingLevel?: ThinkingLevel;
|
|
84
88
|
stream?: boolean;
|
|
85
89
|
}): AnthropicRequest;
|
|
90
|
+
/**
|
|
91
|
+
* Roll prompt-cache breakpoints along the conversation so each turn's context is
|
|
92
|
+
* read back on the next turn instead of being re-billed as fresh input.
|
|
93
|
+
*
|
|
94
|
+
* Two breakpoints, and both are load-bearing:
|
|
95
|
+
*
|
|
96
|
+
* - the **tail** (last block of the last message) writes the entry that the next
|
|
97
|
+
* request reads;
|
|
98
|
+
* - the **anchor** reads it. It goes on the message just before the final
|
|
99
|
+
* assistant message — which is exactly where the previous request ended, since
|
|
100
|
+
* consecutive agent-loop requests differ by one `[assistant, tool results]`
|
|
101
|
+
* step. Landing on the written position directly is what makes this correct
|
|
102
|
+
* for any turn size: Anthropic only looks back 20 content blocks from a
|
|
103
|
+
* breakpoint to find a prior write, and a turn with many parallel tool calls
|
|
104
|
+
* easily exceeds that (1 + 2N blocks for N calls). Measured on Opus 4.8 with
|
|
105
|
+
* 30 parallel calls: tail-only and a fixed-distance anchor both collapse to an
|
|
106
|
+
* 8.6% hit rate, while this anchor holds 77%.
|
|
107
|
+
*
|
|
108
|
+
* Mutates `messages` (built fresh per request by toAnthropicMessages) and
|
|
109
|
+
* returns how many breakpoints it used, never more than `budget`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function placeMessageCacheBreakpoints(messages: AnthropicMessage[], budget: number): number;
|
|
86
112
|
export declare function resolveAnthropicMaxTokens(options: AnthropicProviderOptions, model: string): number;
|
|
87
113
|
export declare function supportsAnthropicPromptCache(options: AnthropicProviderOptions, model: string): boolean;
|
|
88
114
|
export declare function toAnthropicMessages(messages: ProviderMessage[], echoThinking?: boolean): {
|
|
@@ -12,6 +12,8 @@ const ANTHROPIC_LONG_OUTPUT_MAX_TOKENS = 64000;
|
|
|
12
12
|
const MINIMAX_M3_MAX_TOKENS = 128000;
|
|
13
13
|
const MINIMAX_M2_MAX_TOKENS = 64000;
|
|
14
14
|
const ANTHROPIC_PROMPT_CACHE_CONTROL = { type: "ephemeral" };
|
|
15
|
+
/** Hard API limit: a 5th cache_control breakpoint is a 400, not a warning. */
|
|
16
|
+
const ANTHROPIC_MAX_CACHE_BREAKPOINTS = 4;
|
|
15
17
|
const MINIMAX_PROMPT_CACHE_MODELS = new Set([
|
|
16
18
|
"minimax-m2.7",
|
|
17
19
|
"minimax-m2.7-highspeed",
|
|
@@ -89,11 +91,30 @@ export function buildAnthropicRequest(options, messages, chatOptions) {
|
|
|
89
91
|
description: tool.description,
|
|
90
92
|
input_schema: tool.parameters,
|
|
91
93
|
}));
|
|
94
|
+
// Anthropic caps a request at 4 cache_control breakpoints and rejects the
|
|
95
|
+
// fifth outright, so the static ones are counted and the message region only
|
|
96
|
+
// ever gets what is left.
|
|
97
|
+
let staticBreakpoints = 0;
|
|
92
98
|
if (enablePromptCache && tools && tools.length > 0) {
|
|
93
99
|
tools[tools.length - 1] = {
|
|
94
100
|
...tools[tools.length - 1],
|
|
95
101
|
cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL,
|
|
96
102
|
};
|
|
103
|
+
staticBreakpoints += 1;
|
|
104
|
+
}
|
|
105
|
+
if (enablePromptCache && system) {
|
|
106
|
+
staticBreakpoints += 1;
|
|
107
|
+
}
|
|
108
|
+
// Message-region breakpoints go to the official endpoint only. `baseURL` is
|
|
109
|
+
// user-editable persisted config, so a corporate gateway can keep
|
|
110
|
+
// providerId "anthropic" while pointing elsewhere — the URL check is the only
|
|
111
|
+
// trustworthy signal. Streaming only: complete() has no next turn to read the
|
|
112
|
+
// entry back.
|
|
113
|
+
if (enablePromptCache
|
|
114
|
+
&& chatOptions.stream === true
|
|
115
|
+
&& isOfficialAnthropicBaseUrl(options.baseURL)
|
|
116
|
+
&& !messageCacheKillSwitchEnabled()) {
|
|
117
|
+
placeMessageCacheBreakpoints(anthropicMessages, ANTHROPIC_MAX_CACHE_BREAKPOINTS - staticBreakpoints);
|
|
97
118
|
}
|
|
98
119
|
const effectiveThinkingLevel = normalizeThinkingLevel(chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off", getAvailableThinkingLevels(options.providerId || "", chatOptions.model));
|
|
99
120
|
const body = {
|
|
@@ -126,6 +147,95 @@ export function buildAnthropicRequest(options, messages, chatOptions) {
|
|
|
126
147
|
}
|
|
127
148
|
return body;
|
|
128
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Attach `cache_control` to the last cacheable block of `message`.
|
|
152
|
+
*
|
|
153
|
+
* Returns false when the message has nothing markable (empty block array, or a
|
|
154
|
+
* body made only of thinking blocks, where `cache_control` is not permitted).
|
|
155
|
+
*/
|
|
156
|
+
function markLastCacheableBlock(message) {
|
|
157
|
+
if (typeof message.content === "string") {
|
|
158
|
+
if (!message.content)
|
|
159
|
+
return false;
|
|
160
|
+
// A breakpoint needs a block to sit on. Wrapping is safe: the API keys the
|
|
161
|
+
// cache on canonicalised content, so the same message reverting to a bare
|
|
162
|
+
// string on a later turn still matches this entry.
|
|
163
|
+
message.content = [{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: message.content,
|
|
166
|
+
cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL,
|
|
167
|
+
}];
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
const blocks = message.content;
|
|
171
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
172
|
+
const block = blocks[index];
|
|
173
|
+
if (block.type === "thinking" || block.type === "redacted_thinking")
|
|
174
|
+
continue;
|
|
175
|
+
blocks[index] = { ...block, cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL };
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Roll prompt-cache breakpoints along the conversation so each turn's context is
|
|
182
|
+
* read back on the next turn instead of being re-billed as fresh input.
|
|
183
|
+
*
|
|
184
|
+
* Two breakpoints, and both are load-bearing:
|
|
185
|
+
*
|
|
186
|
+
* - the **tail** (last block of the last message) writes the entry that the next
|
|
187
|
+
* request reads;
|
|
188
|
+
* - the **anchor** reads it. It goes on the message just before the final
|
|
189
|
+
* assistant message — which is exactly where the previous request ended, since
|
|
190
|
+
* consecutive agent-loop requests differ by one `[assistant, tool results]`
|
|
191
|
+
* step. Landing on the written position directly is what makes this correct
|
|
192
|
+
* for any turn size: Anthropic only looks back 20 content blocks from a
|
|
193
|
+
* breakpoint to find a prior write, and a turn with many parallel tool calls
|
|
194
|
+
* easily exceeds that (1 + 2N blocks for N calls). Measured on Opus 4.8 with
|
|
195
|
+
* 30 parallel calls: tail-only and a fixed-distance anchor both collapse to an
|
|
196
|
+
* 8.6% hit rate, while this anchor holds 77%.
|
|
197
|
+
*
|
|
198
|
+
* Mutates `messages` (built fresh per request by toAnthropicMessages) and
|
|
199
|
+
* returns how many breakpoints it used, never more than `budget`.
|
|
200
|
+
*/
|
|
201
|
+
export function placeMessageCacheBreakpoints(messages, budget) {
|
|
202
|
+
// One-shot prompts (complete(): compaction, titles, memory) are excluded by
|
|
203
|
+
// the caller's streaming gate, not here — a single-message first turn is a
|
|
204
|
+
// real agent turn and marking its tail costs nothing, since writes are billed
|
|
205
|
+
// from the highest hit to the last breakpoint regardless of how many sit in
|
|
206
|
+
// between.
|
|
207
|
+
if (budget <= 0 || messages.length === 0)
|
|
208
|
+
return 0;
|
|
209
|
+
let used = 0;
|
|
210
|
+
const tailIndex = messages.length - 1;
|
|
211
|
+
if (budget - used >= 2) {
|
|
212
|
+
let lastAssistantIndex = -1;
|
|
213
|
+
for (let index = tailIndex; index >= 0; index--) {
|
|
214
|
+
if (messages[index].role === "assistant") {
|
|
215
|
+
lastAssistantIndex = index;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const anchorIndex = lastAssistantIndex - 1;
|
|
220
|
+
// An assistant at the anchor position means consecutive assistant messages
|
|
221
|
+
// merged, so this is not the previous request's boundary and the distance
|
|
222
|
+
// back is unbounded — skip rather than guess.
|
|
223
|
+
if (anchorIndex >= 0
|
|
224
|
+
&& anchorIndex < tailIndex
|
|
225
|
+
&& messages[anchorIndex].role !== "assistant"
|
|
226
|
+
&& markLastCacheableBlock(messages[anchorIndex])) {
|
|
227
|
+
used += 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (budget - used >= 1 && markLastCacheableBlock(messages[tailIndex])) {
|
|
231
|
+
used += 1;
|
|
232
|
+
}
|
|
233
|
+
return used;
|
|
234
|
+
}
|
|
235
|
+
function messageCacheKillSwitchEnabled() {
|
|
236
|
+
const raw = process.env.BUBBLE_ANTHROPIC_MESSAGE_CACHE;
|
|
237
|
+
return raw === "0" || raw === "false";
|
|
238
|
+
}
|
|
129
239
|
export function resolveAnthropicMaxTokens(options, model) {
|
|
130
240
|
// MiniMax needs a large output budget so thinking doesn't starve the answer.
|
|
131
241
|
if (isMiniMaxProvider(options)) {
|
|
@@ -650,9 +760,18 @@ function mergeAnthropicUsage(current, raw) {
|
|
|
650
760
|
let promptCacheMissTokens = current?.promptCacheMissTokens;
|
|
651
761
|
let cacheCreationTokens = current?.cacheCreationTokens;
|
|
652
762
|
if (hasPromptUsage) {
|
|
653
|
-
|
|
763
|
+
// Every field carries forward: a message_delta may report one of the three
|
|
764
|
+
// without the others, and defaulting a missing one to 0 would deflate
|
|
765
|
+
// promptTokens — which is the compaction anchor (agent lastInputTokens),
|
|
766
|
+
// so an undercount makes the agent compact later than it should.
|
|
767
|
+
// promptCacheMissTokens already includes cache creation, so the prior
|
|
768
|
+
// uncached remainder has to net it back out before being reused.
|
|
769
|
+
const priorUncachedInput = promptCacheMissTokens !== undefined
|
|
770
|
+
? Math.max(0, promptCacheMissTokens - (cacheCreationTokens ?? 0))
|
|
771
|
+
: promptTokens;
|
|
772
|
+
const inputTokens = rawInput ?? priorUncachedInput;
|
|
654
773
|
const cacheRead = rawCacheRead ?? promptCacheHitTokens ?? 0;
|
|
655
|
-
const cacheCreation = rawCacheCreation ?? 0;
|
|
774
|
+
const cacheCreation = rawCacheCreation ?? cacheCreationTokens ?? 0;
|
|
656
775
|
promptTokens = inputTokens + cacheRead + cacheCreation;
|
|
657
776
|
promptCacheHitTokens = cacheRead;
|
|
658
777
|
promptCacheMissTokens = inputTokens + cacheCreation;
|