@animalabs/membrane 0.5.80 → 0.5.81
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/context/index.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/process.d.ts +3 -1
- package/dist/context/process.d.ts.map +1 -1
- package/dist/context/process.js +230 -49
- package/dist/context/process.js.map +1 -1
- package/dist/context/types.d.ts +30 -0
- package/dist/context/types.d.ts.map +1 -1
- package/dist/context/types.js +16 -0
- package/dist/context/types.js.map +1 -1
- package/dist/floating-cache-marker.test.js +21 -0
- package/dist/floating-cache-marker.test.js.map +1 -1
- package/dist/formatters/anthropic-xml.d.ts +2 -0
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +57 -19
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
- package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
- package/dist/formatters/normalize-tool-pairs.js +45 -0
- package/dist/formatters/normalize-tool-pairs.js.map +1 -1
- package/dist/formatters/types.d.ts +29 -0
- package/dist/formatters/types.d.ts.map +1 -1
- package/dist/membrane.d.ts +137 -8
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +812 -310
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +26 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +142 -25
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +24 -1
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/gemini.d.ts +11 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +104 -82
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +2 -0
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +7 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +99 -48
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai-completions.d.ts +5 -0
- package/dist/providers/openai-completions.d.ts.map +1 -1
- package/dist/providers/openai-completions.js +107 -44
- package/dist/providers/openai-completions.js.map +1 -1
- package/dist/providers/openai-responses-api.d.ts +16 -0
- package/dist/providers/openai-responses-api.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.js +38 -10
- package/dist/providers/openai-responses-api.js.map +1 -1
- package/dist/providers/openai-responses.d.ts +2 -0
- package/dist/providers/openai-responses.d.ts.map +1 -1
- package/dist/providers/openai-responses.js +8 -1
- package/dist/providers/openai-responses.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +92 -44
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +9 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +118 -62
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/utils.d.ts +74 -2
- package/dist/providers/utils.d.ts.map +1 -1
- package/dist/providers/utils.js +184 -3
- package/dist/providers/utils.js.map +1 -1
- package/dist/registry/default-pricing.d.ts +22 -0
- package/dist/registry/default-pricing.d.ts.map +1 -1
- package/dist/registry/default-pricing.js +162 -28
- package/dist/registry/default-pricing.js.map +1 -1
- package/dist/types/content.d.ts +13 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +26 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/provider.d.ts +50 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/response.d.ts +85 -4
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +6 -1
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.d.ts +82 -0
- package/dist/utils/cache-marker-budget.d.ts.map +1 -0
- package/dist/utils/cache-marker-budget.js +189 -0
- package/dist/utils/cache-marker-budget.js.map +1 -0
- package/dist/utils/cost.d.ts +22 -0
- package/dist/utils/cost.d.ts.map +1 -1
- package/dist/utils/cost.js +55 -0
- package/dist/utils/cost.js.map +1 -1
- package/dist/utils/thinking-carriers.d.ts +80 -0
- package/dist/utils/thinking-carriers.d.ts.map +1 -0
- package/dist/utils/thinking-carriers.js +115 -0
- package/dist/utils/thinking-carriers.js.map +1 -0
- package/dist/utils/tool-parser.d.ts +24 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +367 -161
- package/dist/utils/tool-parser.js.map +1 -1
- package/dist/utils/usage.d.ts +71 -0
- package/dist/utils/usage.d.ts.map +1 -0
- package/dist/utils/usage.js +161 -0
- package/dist/utils/usage.js.map +1 -0
- package/dist/yielding-stream.d.ts +8 -0
- package/dist/yielding-stream.d.ts.map +1 -1
- package/dist/yielding-stream.js +49 -3
- package/dist/yielding-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/context/index.ts +1 -0
- package/src/context/process.ts +305 -59
- package/src/context/types.ts +38 -0
- package/src/floating-cache-marker.test.ts +24 -0
- package/src/formatters/anthropic-xml.ts +62 -20
- package/src/formatters/normalize-tool-pairs.ts +49 -0
- package/src/formatters/types.ts +32 -0
- package/src/membrane.ts +963 -313
- package/src/providers/anthropic.ts +161 -19
- package/src/providers/bedrock.ts +25 -1
- package/src/providers/gemini.ts +125 -87
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-compatible.ts +102 -45
- package/src/providers/openai-completions.ts +112 -46
- package/src/providers/openai-responses-api.ts +45 -15
- package/src/providers/openai-responses.ts +8 -1
- package/src/providers/openai.ts +95 -42
- package/src/providers/openrouter.ts +125 -63
- package/src/providers/utils.ts +227 -6
- package/src/registry/default-pricing.ts +165 -28
- package/src/types/content.ts +14 -0
- package/src/types/errors.ts +28 -0
- package/src/types/index.ts +6 -0
- package/src/types/provider.ts +55 -0
- package/src/types/response.ts +109 -8
- package/src/types/streaming.ts +8 -2
- package/src/utils/cache-marker-budget.ts +208 -0
- package/src/utils/cost.ts +64 -0
- package/src/utils/thinking-carriers.ts +125 -0
- package/src/utils/tool-parser.ts +473 -174
- package/src/utils/usage.ts +184 -0
- package/src/yielding-stream.ts +54 -3
package/src/types/response.ts
CHANGED
|
@@ -30,20 +30,77 @@ export interface BasicUsage {
|
|
|
30
30
|
outputTokens: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* What ONE priced unit of work cost — a turn, or the discarded attempts summed
|
|
35
|
+
* together. Held apart from `DetailedUsage` so the discarded-spend record can
|
|
36
|
+
* carry every token field without also inheriting `discardedAttempts`, which
|
|
37
|
+
* would let the type describe discarded spend nested inside discarded spend:
|
|
38
|
+
* a shape nothing produces and nothing could read sensibly.
|
|
39
|
+
*/
|
|
40
|
+
export interface CallUsage extends BasicUsage {
|
|
34
41
|
/** Tokens used for cache creation */
|
|
35
42
|
cacheCreationTokens?: number;
|
|
36
|
-
|
|
43
|
+
|
|
37
44
|
/** Tokens read from cache */
|
|
38
45
|
cacheReadTokens?: number;
|
|
39
|
-
|
|
40
|
-
/**
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Thinking/reasoning tokens the provider reported separately from its
|
|
49
|
+
* visible-output count, already INCLUDED in `outputTokens` (they are billed
|
|
50
|
+
* at the output rate). Surfaced so a caller can attribute spend to thinking;
|
|
51
|
+
* summing it with `outputTokens` would double-count. Gemini's
|
|
52
|
+
* `thoughtsTokenCount` is the current source.
|
|
53
|
+
*/
|
|
41
54
|
thinkingTokens?: number;
|
|
42
|
-
|
|
55
|
+
|
|
43
56
|
/** Estimated cost breakdown */
|
|
44
57
|
estimatedCost?: CostBreakdown;
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
export interface DetailedUsage extends CallUsage {
|
|
61
|
+
/**
|
|
62
|
+
* Spend on provider calls whose output was thrown away — today, refusal
|
|
63
|
+
* retries. Those attempts were completed, billed HTTP calls; the response
|
|
64
|
+
* describes only the attempt that STANDS, so without this the real cost of
|
|
65
|
+
* a turn is invisible. Absent when nothing was discarded.
|
|
66
|
+
*
|
|
67
|
+
* Reported on `details.usage` only: the top-level `usage` stays the
|
|
68
|
+
* surviving attempt's, so existing consumers keep their meaning.
|
|
69
|
+
*/
|
|
70
|
+
discardedAttempts?: DiscardedAttemptsUsage;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DiscardedAttemptsUsage extends CallUsage {
|
|
74
|
+
/** How many billed-but-abandoned provider calls are summed here. */
|
|
75
|
+
attempts: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Assert<TCondition extends true> = TCondition;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Erased at build; checked by `tsc --noEmit`, which covers src/ and not the
|
|
82
|
+
* test suite — so this is where a type-level guarantee can actually fail the
|
|
83
|
+
* build. Re-widening the discarded record to `DetailedUsage` reintroduces
|
|
84
|
+
* discarded-spend-inside-discarded-spend and turns this line red.
|
|
85
|
+
*/
|
|
86
|
+
type DiscardedSpendDoesNotNest = Assert<
|
|
87
|
+
'discardedAttempts' extends keyof DiscardedAttemptsUsage ? false : true
|
|
88
|
+
>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One provider round of a turn: the model that served it and what that round
|
|
92
|
+
* alone used and cost. `usage.estimatedCost` here is priced at THIS round's
|
|
93
|
+
* model, which is why the rounds can be summed into a turn total that a
|
|
94
|
+
* multi-model turn's bill actually matches.
|
|
95
|
+
*/
|
|
96
|
+
export interface TurnRoundUsage {
|
|
97
|
+
/** Model the provider named as having served this round; the requested id when it named none. */
|
|
98
|
+
model: string;
|
|
99
|
+
|
|
100
|
+
/** This round's own tokens and its own cost. */
|
|
101
|
+
usage: DetailedUsage;
|
|
102
|
+
}
|
|
103
|
+
|
|
47
104
|
export interface CostBreakdown {
|
|
48
105
|
input: number;
|
|
49
106
|
output: number;
|
|
@@ -51,6 +108,13 @@ export interface CostBreakdown {
|
|
|
51
108
|
cacheRead?: number;
|
|
52
109
|
total: number;
|
|
53
110
|
currency: string;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ISO date the rates behind this breakdown were last verified against the
|
|
114
|
+
* provider's published prices, when the pricing source records one. Unset
|
|
115
|
+
* means the source vouches for no date, NOT that the numbers are current.
|
|
116
|
+
*/
|
|
117
|
+
pricingAsOf?: string;
|
|
54
118
|
}
|
|
55
119
|
|
|
56
120
|
// ============================================================================
|
|
@@ -65,6 +129,16 @@ export interface StopInfo {
|
|
|
65
129
|
|
|
66
130
|
/** Whether output was truncated */
|
|
67
131
|
wasTruncated: boolean;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* XML tool mode: the turn ended with a tool block still open — a
|
|
135
|
+
* `<function_calls>` opener with no closer, or text cut mid-tag. The loop
|
|
136
|
+
* does not resume on a length stop, so this is the shape a max_tokens
|
|
137
|
+
* truncation leaves behind. A consumer persisting the turn must not write it
|
|
138
|
+
* back bare: on the next round the stale opener would be read as part of that
|
|
139
|
+
* round's block.
|
|
140
|
+
*/
|
|
141
|
+
unclosedToolBlock?: boolean;
|
|
68
142
|
}
|
|
69
143
|
|
|
70
144
|
// ============================================================================
|
|
@@ -75,11 +149,26 @@ export interface ModelInfo {
|
|
|
75
149
|
/** Model ID that was requested */
|
|
76
150
|
requested: string;
|
|
77
151
|
|
|
78
|
-
/** Model ID that actually ran (may differ due to routing/fallback)
|
|
152
|
+
/** Model ID that actually ran (may differ due to routing/fallback). On a
|
|
153
|
+
* multi-round turn this is the model that served the LAST round; see
|
|
154
|
+
* {@link perRound} for the whole roster. */
|
|
79
155
|
actual: string;
|
|
80
156
|
|
|
81
157
|
/** Provider that served the request */
|
|
82
158
|
provider: string;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Every provider round of this turn in order, each naming the model that
|
|
162
|
+
* served it and what that round alone used and cost — the audit trail behind
|
|
163
|
+
* `usage.estimatedCost`, which is their sum. A routed turn can change models
|
|
164
|
+
* mid-turn (OpenRouter re-picks a provider per call), so `actual` alone
|
|
165
|
+
* cannot say what was billed at which rate.
|
|
166
|
+
*
|
|
167
|
+
* Set on the streaming/tool-loop paths, which are the ones that sum. Unset
|
|
168
|
+
* on `complete()`, which makes exactly one call: `actual` is the whole story
|
|
169
|
+
* there.
|
|
170
|
+
*/
|
|
171
|
+
perRound?: TurnRoundUsage[];
|
|
83
172
|
}
|
|
84
173
|
|
|
85
174
|
// ============================================================================
|
|
@@ -96,9 +185,21 @@ export interface TimingInfo {
|
|
|
96
185
|
/** Tokens per second (streaming only) */
|
|
97
186
|
tokensPerSecond?: number;
|
|
98
187
|
|
|
99
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* Provider calls this turn actually cost: retries plus, on the streaming
|
|
190
|
+
* paths, every continuation round and refusal re-issue. A stitched
|
|
191
|
+
* multi-call turn used to report 1 here, indistinguishable in durable
|
|
192
|
+
* logs from a single-shot one.
|
|
193
|
+
*/
|
|
100
194
|
attempts: number;
|
|
101
|
-
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Continuation rounds that made up the turn — tool rounds and automatic
|
|
198
|
+
* resumptions. 1 for a single-round turn; lower than `attempts` whenever a
|
|
199
|
+
* round was re-issued. Streaming paths only.
|
|
200
|
+
*/
|
|
201
|
+
rounds?: number;
|
|
202
|
+
|
|
102
203
|
/** Delay between retries */
|
|
103
204
|
retryDelaysMs?: number[];
|
|
104
205
|
}
|
package/src/types/streaming.ts
CHANGED
|
@@ -194,9 +194,15 @@ export interface StreamOptions {
|
|
|
194
194
|
/** Abort signal for cancellation */
|
|
195
195
|
signal?: AbortSignal;
|
|
196
196
|
|
|
197
|
-
/** Request timeout */
|
|
197
|
+
/** Request timeout (per provider call, not per turn) */
|
|
198
198
|
timeoutMs?: number;
|
|
199
|
-
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Abort a call whose stream goes silent for this many ms. Parity with
|
|
202
|
+
* YieldingStreamOptions.idleTimeoutMs; the adapter owns the default.
|
|
203
|
+
*/
|
|
204
|
+
idleTimeoutMs?: number;
|
|
205
|
+
|
|
200
206
|
/** Request ID for correlation */
|
|
201
207
|
requestId?: string;
|
|
202
208
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One recount of the cache_control markers actually present on a wire
|
|
3
|
+
* request, plus the clamp that keeps that count legal.
|
|
4
|
+
*
|
|
5
|
+
* Anthropic accepts at most 4 cache_control breakpoints per request and
|
|
6
|
+
* rejects the fifth with a non-retryable 400 ("A maximum of 4 blocks with
|
|
7
|
+
* cache_control may be provided. Found 5." — measured live 2026-08-25).
|
|
8
|
+
* Markers arrive from many sites (context strategy breakpoints, block-level
|
|
9
|
+
* passthrough on imported histories, the tools/system fallback, the floating
|
|
10
|
+
* tool-loop marker, caller-marked system blocks, and any beforeRequest hook),
|
|
11
|
+
* and every one of them is invisible to the others' running tallies. The
|
|
12
|
+
* only trustworthy number is a recount of the constructed artifacts, so this
|
|
13
|
+
* module owns it and every site calls it.
|
|
14
|
+
*
|
|
15
|
+
* A marker can also ride a block NESTED inside another block's content array
|
|
16
|
+
* (`tool_result.content` is `string | ContentBlock[]`, passed through to the
|
|
17
|
+
* wire verbatim), so discovery recurses. A belt that cannot see a marker
|
|
18
|
+
* cannot clamp it, and a top-level-only count would report four while five
|
|
19
|
+
* shipped — the request rejected outright with the belt's blessing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Anthropic's hard limit on cache_control breakpoints per request. */
|
|
23
|
+
export const MAX_CACHE_BREAKPOINTS = 4;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How many levels of nested `content` arrays marker discovery descends below
|
|
27
|
+
* a top-level block.
|
|
28
|
+
*
|
|
29
|
+
* A marker can ride a block inside another block's own content array —
|
|
30
|
+
* `ToolResultContent.content` is typed `string | ContentBlock[]` and every
|
|
31
|
+
* builder passes it through verbatim, so a caller-built tool_result whose
|
|
32
|
+
* members carry `cache_control` puts real markers on the wire. The API's own
|
|
33
|
+
* nesting is one level deep; 4 is headroom for shapes it grows later, and it
|
|
34
|
+
* is also what makes this walk TOTAL — a caller-built structure that points
|
|
35
|
+
* back at itself terminates at the cap instead of hanging the request.
|
|
36
|
+
* Markers below the cap are outside the belt's reach, by construction.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_NESTED_CONTENT_DEPTH = 4;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Walk one candidate block and everything nested under it, in document order
|
|
42
|
+
* (a block before its own content), applying `visitBlock` to each object.
|
|
43
|
+
*
|
|
44
|
+
* This is the single traversal law: marker discovery and the ownership copy
|
|
45
|
+
* that protects caller-owned blocks from it both descend exactly here, so
|
|
46
|
+
* the clamp can never reach a grain that ownership did not copy.
|
|
47
|
+
*/
|
|
48
|
+
function walkBlockTree(
|
|
49
|
+
candidate: unknown,
|
|
50
|
+
depth: number,
|
|
51
|
+
visitBlock: (block: Record<string, unknown>) => void
|
|
52
|
+
): void {
|
|
53
|
+
if (!candidate || typeof candidate !== 'object') return;
|
|
54
|
+
const block = candidate as Record<string, unknown>;
|
|
55
|
+
visitBlock(block);
|
|
56
|
+
const nested = nestedContentBlocks(block, depth);
|
|
57
|
+
if (!nested) return;
|
|
58
|
+
for (const child of nested) walkBlockTree(child, depth + 1, visitBlock);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The one rule for what counts as nested content: an array-valued `content`
|
|
63
|
+
* on a block, while still above the depth cap. Discovery and the ownership
|
|
64
|
+
* copy both ask here, so neither can descend where the other does not.
|
|
65
|
+
*/
|
|
66
|
+
function nestedContentBlocks(block: Record<string, unknown>, depth: number): unknown[] | undefined {
|
|
67
|
+
if (depth >= MAX_NESTED_CONTENT_DEPTH) return undefined;
|
|
68
|
+
return Array.isArray(block.content) ? block.content : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WireCacheSurfaces {
|
|
72
|
+
messages?: unknown;
|
|
73
|
+
system?: unknown;
|
|
74
|
+
tools?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every marker-bearing block on the request, in WIRE ORDER (tools, then
|
|
79
|
+
* system blocks, then message blocks) and, within each, in DOCUMENT order —
|
|
80
|
+
* a block before anything nested inside its own content. Order is what makes
|
|
81
|
+
* "deepest" mean anything: a deeper marker caches a longer prefix.
|
|
82
|
+
*
|
|
83
|
+
* ONE walk serves both the count and the clamp's strip, so a marker the
|
|
84
|
+
* counter can see is always a marker the clamp can strip. A collector that
|
|
85
|
+
* stopped at the top level would count four while five rode to the wire, and
|
|
86
|
+
* the provider would reject the request the belt just declared legal.
|
|
87
|
+
*/
|
|
88
|
+
function collectMarkedBlocks(surfaces: WireCacheSurfaces): Array<Record<string, unknown>> {
|
|
89
|
+
const marked: Array<Record<string, unknown>> = [];
|
|
90
|
+
const collectIfMarked = (candidate: unknown) =>
|
|
91
|
+
walkBlockTree(candidate, 0, (block) => {
|
|
92
|
+
if (block.cache_control) marked.push(block);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (Array.isArray(surfaces.tools)) {
|
|
96
|
+
for (const tool of surfaces.tools) collectIfMarked(tool);
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(surfaces.system)) {
|
|
99
|
+
for (const block of surfaces.system) collectIfMarked(block);
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(surfaces.messages)) {
|
|
102
|
+
for (const message of surfaces.messages) {
|
|
103
|
+
const content = (message as { content?: unknown } | null)?.content;
|
|
104
|
+
if (Array.isArray(content)) {
|
|
105
|
+
for (const block of content) collectIfMarked(block);
|
|
106
|
+
} else {
|
|
107
|
+
collectIfMarked(content);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return marked;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Take ownership of a system surface before it can reach the clamp.
|
|
116
|
+
*
|
|
117
|
+
* `request.system` accepts caller-marked blocks, and the builders pass that
|
|
118
|
+
* array through by reference when they add no marker of their own. The clamp
|
|
119
|
+
* strips markers IN PLACE at the wire boundary — correct for blocks membrane
|
|
120
|
+
* built, catastrophic for the caller's own array, which a long-lived caller
|
|
121
|
+
* reuses turn after turn: one over-budget request would silently delete the
|
|
122
|
+
* caller's breakpoints for the life of that object. Copying the array and its
|
|
123
|
+
* blocks at build time keeps the clamp's mutations inside the request.
|
|
124
|
+
*
|
|
125
|
+
* The copy descends exactly as far as marker discovery does: a shallow
|
|
126
|
+
* `{...block}` leaves any nested `content` array shared with the caller, and
|
|
127
|
+
* the clamp now strips markers at that grain, so ownership has to reach it
|
|
128
|
+
* too or the leak just moves one level down.
|
|
129
|
+
*/
|
|
130
|
+
export function ownSystemBlocks(system: unknown): unknown {
|
|
131
|
+
if (!Array.isArray(system)) return system;
|
|
132
|
+
return system.map((block) => copyBlockTree(block, 0));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Shallow-copy a block and, recursively, its nested content blocks, to the
|
|
137
|
+
* same depth marker discovery walks. Non-objects pass through untouched.
|
|
138
|
+
*/
|
|
139
|
+
function copyBlockTree(candidate: unknown, depth: number): unknown {
|
|
140
|
+
if (!candidate || typeof candidate !== 'object') return candidate;
|
|
141
|
+
const copy = { ...(candidate as Record<string, unknown>) };
|
|
142
|
+
const nested = nestedContentBlocks(copy, depth);
|
|
143
|
+
if (nested) copy.content = nested.map((child) => copyBlockTree(child, depth + 1));
|
|
144
|
+
return copy;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** How many cache_control markers this request would actually put on the wire. */
|
|
148
|
+
export function countWireCacheMarkers(surfaces: WireCacheSurfaces): number {
|
|
149
|
+
return collectMarkedBlocks(surfaces).length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Bring a request inside the breakpoint budget, in place, at the last exit
|
|
154
|
+
* before the adapter call. Two repairs, both loud:
|
|
155
|
+
*
|
|
156
|
+
* 1. a marker riding a thinking / redacted_thinking block is stripped — the
|
|
157
|
+
* API rejects those outright ("thinking.cache_control: Extra inputs are
|
|
158
|
+
* not permitted"), and the three builders' `lastCacheableBlockIndex`
|
|
159
|
+
* discipline is only as good as its last caller. This is the runtime
|
|
160
|
+
* assertion; a fourth builder can no longer repeat that history.
|
|
161
|
+
* 2. markers past the limit are dropped SHALLOWEST-FIRST, keeping the 4
|
|
162
|
+
* deepest — the deepest marker caches the longest prefix, and every
|
|
163
|
+
* shallower prefix it subsumes.
|
|
164
|
+
*
|
|
165
|
+
* Dropping loudly beats a 400: the request still ships, cached less than the
|
|
166
|
+
* caller asked for, with the overspend named.
|
|
167
|
+
*/
|
|
168
|
+
export function clampCacheMarkers(
|
|
169
|
+
surfaces: WireCacheSurfaces,
|
|
170
|
+
site: string
|
|
171
|
+
): { total: number; dropped: number; strippedFromThinking: number } {
|
|
172
|
+
const marked = collectMarkedBlocks(surfaces);
|
|
173
|
+
|
|
174
|
+
let strippedFromThinking = 0;
|
|
175
|
+
const cacheable: Array<Record<string, unknown>> = [];
|
|
176
|
+
for (const block of marked) {
|
|
177
|
+
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
|
|
178
|
+
delete block.cache_control;
|
|
179
|
+
strippedFromThinking++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
cacheable.push(block);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let dropped = 0;
|
|
186
|
+
if (cacheable.length > MAX_CACHE_BREAKPOINTS) {
|
|
187
|
+
for (const block of cacheable.slice(0, cacheable.length - MAX_CACHE_BREAKPOINTS)) {
|
|
188
|
+
delete block.cache_control;
|
|
189
|
+
dropped++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (strippedFromThinking > 0) {
|
|
194
|
+
console.warn(
|
|
195
|
+
`[membrane] ${site}: stripped ${strippedFromThinking} cache_control marker(s) from ` +
|
|
196
|
+
`thinking blocks — the API rejects cache_control on thinking/redacted_thinking.`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (dropped > 0) {
|
|
200
|
+
console.warn(
|
|
201
|
+
`[membrane] ${site}: ${cacheable.length} cache_control markers exceed the limit of ` +
|
|
202
|
+
`${MAX_CACHE_BREAKPOINTS} — dropped the ${dropped} shallowest and kept the deepest ` +
|
|
203
|
+
`${MAX_CACHE_BREAKPOINTS}. The request would otherwise have been rejected outright.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { total: cacheable.length - dropped, dropped, strippedFromThinking };
|
|
208
|
+
}
|
package/src/utils/cost.ts
CHANGED
|
@@ -8,6 +8,69 @@ export interface CostableUsage {
|
|
|
8
8
|
cacheReadTokens?: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Unpriced models warn once each, not once per call. */
|
|
12
|
+
const warnedUnpricedModels = new Set<string>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Say once, per model, that membrane has no rates for it — so an omitted
|
|
16
|
+
* `estimatedCost` reads as "unknown" rather than "free" to a caller who only
|
|
17
|
+
* ever sees the absence.
|
|
18
|
+
*/
|
|
19
|
+
export function warnUnpricedModel(modelId: string): void {
|
|
20
|
+
if (warnedUnpricedModels.has(modelId)) return;
|
|
21
|
+
warnedUnpricedModels.add(modelId);
|
|
22
|
+
console.warn(
|
|
23
|
+
`[membrane:cost] no pricing for model "${modelId}" — neither the configured`
|
|
24
|
+
+ ' ModelRegistry nor the built-in table has rates for it, so estimatedCost will be'
|
|
25
|
+
+ ' omitted (this is NOT a cost of zero). Add it to your registry to get cost estimates.'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Test seam: the once-per-model warn latch is process-wide otherwise. */
|
|
30
|
+
export function resetUnpricedModelWarnings(): void {
|
|
31
|
+
warnedUnpricedModels.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Add two breakdowns that may have been priced at DIFFERENT rates — the
|
|
36
|
+
* multi-round case, where each round is priced under the model that served it
|
|
37
|
+
* and the turn total is their sum.
|
|
38
|
+
*
|
|
39
|
+
* `pricingAsOf` takes the OLDER of the two: a total is only as fresh as its
|
|
40
|
+
* stalest input, and a round whose source vouches for NO date is stalest of
|
|
41
|
+
* all — one unstamped round leaves the sum unstamped. A currency mismatch is
|
|
42
|
+
* not summable at all (the caller's
|
|
43
|
+
* registry priced two rounds in different currencies and membrane has no rate
|
|
44
|
+
* to convert with), so it returns undefined rather than adding dollars to
|
|
45
|
+
* euros — an absent cost says "membrane does not know", which is true.
|
|
46
|
+
*/
|
|
47
|
+
export function addCostBreakdowns(
|
|
48
|
+
a: CostBreakdown,
|
|
49
|
+
b: CostBreakdown,
|
|
50
|
+
): CostBreakdown | undefined {
|
|
51
|
+
if (a.currency !== b.currency) return undefined;
|
|
52
|
+
|
|
53
|
+
const cacheWrite = a.cacheWrite != null || b.cacheWrite != null
|
|
54
|
+
? (a.cacheWrite ?? 0) + (b.cacheWrite ?? 0)
|
|
55
|
+
: undefined;
|
|
56
|
+
const cacheRead = a.cacheRead != null || b.cacheRead != null
|
|
57
|
+
? (a.cacheRead ?? 0) + (b.cacheRead ?? 0)
|
|
58
|
+
: undefined;
|
|
59
|
+
const oldestAsOf = a.pricingAsOf && b.pricingAsOf
|
|
60
|
+
? (a.pricingAsOf < b.pricingAsOf ? a.pricingAsOf : b.pricingAsOf)
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
input: a.input + b.input,
|
|
65
|
+
output: a.output + b.output,
|
|
66
|
+
cacheWrite,
|
|
67
|
+
cacheRead,
|
|
68
|
+
total: a.total + b.total,
|
|
69
|
+
currency: a.currency,
|
|
70
|
+
...(oldestAsOf ? { pricingAsOf: oldestAsOf } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
11
74
|
export function calculateCost(usage: CostableUsage, pricing: ModelPricing): CostBreakdown {
|
|
12
75
|
const input = usage.inputTokens * pricing.inputPerMillion / 1_000_000;
|
|
13
76
|
const output = usage.outputTokens * pricing.outputPerMillion / 1_000_000;
|
|
@@ -25,5 +88,6 @@ export function calculateCost(usage: CostableUsage, pricing: ModelPricing): Cost
|
|
|
25
88
|
cacheRead,
|
|
26
89
|
total: input + output + (cacheWrite ?? 0) + (cacheRead ?? 0),
|
|
27
90
|
currency: pricing.currency,
|
|
91
|
+
...(pricing.asOf ? { pricingAsOf: pricing.asOf } : {}),
|
|
28
92
|
};
|
|
29
93
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for pairing provider-native thinking carriers (which hold the
|
|
3
|
+
* signatures) with parser-derived thinking blocks (which hold the text).
|
|
4
|
+
* See Membrane.mergeProviderThinkingBlocks for the pairing rules.
|
|
5
|
+
*/
|
|
6
|
+
import type { ContentBlock } from '../types/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Identity for thinking text, insensitive to exactly two artifacts that ride
|
|
10
|
+
* the parser's view of the same reasoning — and to nothing else:
|
|
11
|
+
*
|
|
12
|
+
* - stream scaffolding: the XML path prefills `Claude: <thinking>` and asks
|
|
13
|
+
* the adapter to wrap native thinking deltas, so a parsed block can carry
|
|
14
|
+
* a literal `<thinking>` / `</thinking>` tag the provider block never had.
|
|
15
|
+
* - OUTER boundary whitespace: the continuation path trims the accumulation
|
|
16
|
+
* at each round boundary (`buildContinuationRequest` trimEnds before
|
|
17
|
+
* re-prefilling), so a fragment and the parsed text can differ by leading
|
|
18
|
+
* or trailing whitespace.
|
|
19
|
+
*
|
|
20
|
+
* INTERNAL whitespace is payload and is compared verbatim. Erasing it (the
|
|
21
|
+
* first implementation stripped every `\s`) makes distinct signed payloads
|
|
22
|
+
* collide on nothing but where their spaces fall, and the caller then stamps
|
|
23
|
+
* one payload's signature onto the other's text — a carrier that fails
|
|
24
|
+
* Anthropic signature validation when the consumer ships its history back.
|
|
25
|
+
*
|
|
26
|
+
* Comparison only — stored text is never rewritten. Empty text is never
|
|
27
|
+
* identical to anything: signature-only carriers are prepend-only.
|
|
28
|
+
*/
|
|
29
|
+
export function sameThinkingText(left: string, right: string): boolean {
|
|
30
|
+
if (left === right) return left !== '';
|
|
31
|
+
if (left === '' || right === '') return false;
|
|
32
|
+
const normalized = (text: string) => text.replace(/<\/?(antml:)?thinking>/g, '').trim();
|
|
33
|
+
const normalizedLeft = normalized(left);
|
|
34
|
+
if (normalizedLeft === '') return false;
|
|
35
|
+
return normalizedLeft === normalized(right);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find a RUN of consecutive still-unpaired provider blocks whose concatenated
|
|
40
|
+
* thinking reconstructs `parsedText` (an auto-continuation split across a
|
|
41
|
+
* max_tokens boundary). Returns the provider indices in order, or undefined
|
|
42
|
+
* when no run of two or more reconstructs it.
|
|
43
|
+
*
|
|
44
|
+
* Two joins are tried, because the round boundary itself is the one place a
|
|
45
|
+
* fragment's whitespace can legitimately vanish: `buildContinuationRequest`
|
|
46
|
+
* trimEnds the accumulation before re-prefilling, so the resumed text may
|
|
47
|
+
* pick up exactly where the trim left off. Modelling that single
|
|
48
|
+
* transformation keeps internal whitespace comparable everywhere else —
|
|
49
|
+
* `sameThinkingText` treats it as payload.
|
|
50
|
+
*/
|
|
51
|
+
export function findSpanningProviderRun(
|
|
52
|
+
providerThinking: Array<{ thinking?: string; signature?: string }>,
|
|
53
|
+
pairedProviderBlocks: ReadonlySet<number>,
|
|
54
|
+
parsedText: string
|
|
55
|
+
): number[] | undefined {
|
|
56
|
+
for (let start = 0; start < providerThinking.length; start++) {
|
|
57
|
+
if (pairedProviderBlocks.has(start)) continue;
|
|
58
|
+
const firstFragment = providerThinking[start]!.thinking ?? '';
|
|
59
|
+
let verbatimJoin = firstFragment;
|
|
60
|
+
let trimmedJoin = firstFragment;
|
|
61
|
+
const run = [start];
|
|
62
|
+
for (let next = start + 1; next < providerThinking.length; next++) {
|
|
63
|
+
if (pairedProviderBlocks.has(next)) break;
|
|
64
|
+
const fragment = providerThinking[next]!.thinking ?? '';
|
|
65
|
+
verbatimJoin += fragment;
|
|
66
|
+
trimmedJoin = trimmedJoin.trimEnd() + fragment;
|
|
67
|
+
run.push(next);
|
|
68
|
+
if (sameThinkingText(verbatimJoin, parsedText) || sameThinkingText(trimmedJoin, parsedText)) {
|
|
69
|
+
return [...run];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Drop the extended-thinking config from a prefill-shaped provider request —
|
|
78
|
+
* from BOTH channels the `thinkingEnabled` resolver reads.
|
|
79
|
+
*
|
|
80
|
+
* Whether the API rejects thinking beside an assistant prefill is MODEL
|
|
81
|
+
* DEPENDENT, not a universal law. Measured live 2026-08-25 against the
|
|
82
|
+
* Anthropic Messages API: `claude-haiku-4-5-20251001` accepts an assistant
|
|
83
|
+
* prefill together with `thinking: {type: 'enabled'}` (HTTP 200, and the
|
|
84
|
+
* response carries no thinking block), while `claude-sonnet-4-6` refuses
|
|
85
|
+
* assistant prefill outright — HTTP 400, "This model does not support
|
|
86
|
+
* assistant message prefill."
|
|
87
|
+
*
|
|
88
|
+
* So the strip is the prefill path's DESIGN wherever prefill works: the XML
|
|
89
|
+
* formatter uses the thinking config to emit a literal `<thinking>` text
|
|
90
|
+
* prefix rather than the API feature, and sending both pays for API thinking
|
|
91
|
+
* the prefill format does not consume. On models that reject the combination
|
|
92
|
+
* it is additionally the 400 guard it always was. Either way both channels
|
|
93
|
+
* must agree, and the guard that only deleted the top-level field left
|
|
94
|
+
* `extra.thinking` (spread from `providerParams`) riding the adapter's
|
|
95
|
+
* `Object.assign(params, rest)` onto the wire — reproducing both the 400 and
|
|
96
|
+
* the interleaved-thinking beta header, since the resolver correctly saw the
|
|
97
|
+
* smuggled config. Mutates and returns the request; callers must own `extra`
|
|
98
|
+
* (never alias the caller's `providerParams`).
|
|
99
|
+
*/
|
|
100
|
+
export function stripThinkingForPrefill<T extends { thinking?: unknown; extra?: unknown }>(
|
|
101
|
+
providerRequest: T
|
|
102
|
+
): T {
|
|
103
|
+
delete providerRequest.thinking;
|
|
104
|
+
const extra = providerRequest.extra;
|
|
105
|
+
if (extra && typeof extra === 'object') {
|
|
106
|
+
delete (extra as Record<string, unknown>).thinking;
|
|
107
|
+
}
|
|
108
|
+
return providerRequest;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* De-duplication key for a thinking / redacted_thinking carrier: two carriers
|
|
113
|
+
* are the same carrier when their payload and signature match. Any other block
|
|
114
|
+
* type gets a unique key so it can never collide.
|
|
115
|
+
*/
|
|
116
|
+
export function thinkingCarrierKey(block: ContentBlock): string {
|
|
117
|
+
if (block.type === 'thinking') {
|
|
118
|
+
const { thinking, signature } = block as { thinking?: string; signature?: string };
|
|
119
|
+
return `thinking\u0000${thinking ?? ''}\u0000${signature ?? ''}`;
|
|
120
|
+
}
|
|
121
|
+
if (block.type === 'redacted_thinking') {
|
|
122
|
+
return `redacted\u0000${(block as unknown as { data?: string }).data ?? ''}`;
|
|
123
|
+
}
|
|
124
|
+
return `other\u0000${JSON.stringify(block)}`;
|
|
125
|
+
}
|