@animalabs/membrane 0.5.80 → 0.5.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/cache-wire-receipt.d.ts +13 -0
  2. package/dist/cache-wire-receipt.d.ts.map +1 -0
  3. package/dist/cache-wire-receipt.js +108 -0
  4. package/dist/cache-wire-receipt.js.map +1 -0
  5. package/dist/context/index.d.ts +1 -1
  6. package/dist/context/index.d.ts.map +1 -1
  7. package/dist/context/index.js +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/context/process.d.ts +3 -1
  10. package/dist/context/process.d.ts.map +1 -1
  11. package/dist/context/process.js +230 -49
  12. package/dist/context/process.js.map +1 -1
  13. package/dist/context/types.d.ts +30 -0
  14. package/dist/context/types.d.ts.map +1 -1
  15. package/dist/context/types.js +16 -0
  16. package/dist/context/types.js.map +1 -1
  17. package/dist/floating-cache-marker.test.js +21 -0
  18. package/dist/floating-cache-marker.test.js.map +1 -1
  19. package/dist/formatters/anthropic-xml.d.ts +2 -0
  20. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  21. package/dist/formatters/anthropic-xml.js +64 -23
  22. package/dist/formatters/anthropic-xml.js.map +1 -1
  23. package/dist/formatters/native.d.ts.map +1 -1
  24. package/dist/formatters/native.js +16 -3
  25. package/dist/formatters/native.js.map +1 -1
  26. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  27. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  28. package/dist/formatters/normalize-tool-pairs.js +45 -0
  29. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  30. package/dist/formatters/types.d.ts +31 -0
  31. package/dist/formatters/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/membrane.d.ts +137 -8
  37. package/dist/membrane.d.ts.map +1 -1
  38. package/dist/membrane.js +823 -310
  39. package/dist/membrane.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts +37 -0
  41. package/dist/providers/anthropic.d.ts.map +1 -1
  42. package/dist/providers/anthropic.js +142 -25
  43. package/dist/providers/anthropic.js.map +1 -1
  44. package/dist/providers/bedrock.d.ts +8 -0
  45. package/dist/providers/bedrock.d.ts.map +1 -1
  46. package/dist/providers/bedrock.js +24 -1
  47. package/dist/providers/bedrock.js.map +1 -1
  48. package/dist/providers/gemini.d.ts +11 -0
  49. package/dist/providers/gemini.d.ts.map +1 -1
  50. package/dist/providers/gemini.js +104 -82
  51. package/dist/providers/gemini.js.map +1 -1
  52. package/dist/providers/index.d.ts +1 -1
  53. package/dist/providers/index.d.ts.map +1 -1
  54. package/dist/providers/index.js.map +1 -1
  55. package/dist/providers/mock.d.ts +2 -0
  56. package/dist/providers/mock.d.ts.map +1 -1
  57. package/dist/providers/mock.js +2 -0
  58. package/dist/providers/mock.js.map +1 -1
  59. package/dist/providers/openai-compatible.d.ts +7 -0
  60. package/dist/providers/openai-compatible.d.ts.map +1 -1
  61. package/dist/providers/openai-compatible.js +99 -48
  62. package/dist/providers/openai-compatible.js.map +1 -1
  63. package/dist/providers/openai-completions.d.ts +5 -0
  64. package/dist/providers/openai-completions.d.ts.map +1 -1
  65. package/dist/providers/openai-completions.js +107 -44
  66. package/dist/providers/openai-completions.js.map +1 -1
  67. package/dist/providers/openai-responses-api.d.ts +16 -0
  68. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  69. package/dist/providers/openai-responses-api.js +38 -10
  70. package/dist/providers/openai-responses-api.js.map +1 -1
  71. package/dist/providers/openai-responses.d.ts +2 -0
  72. package/dist/providers/openai-responses.d.ts.map +1 -1
  73. package/dist/providers/openai-responses.js +8 -1
  74. package/dist/providers/openai-responses.js.map +1 -1
  75. package/dist/providers/openai.d.ts +7 -0
  76. package/dist/providers/openai.d.ts.map +1 -1
  77. package/dist/providers/openai.js +92 -44
  78. package/dist/providers/openai.js.map +1 -1
  79. package/dist/providers/openrouter.d.ts +9 -0
  80. package/dist/providers/openrouter.d.ts.map +1 -1
  81. package/dist/providers/openrouter.js +118 -62
  82. package/dist/providers/openrouter.js.map +1 -1
  83. package/dist/providers/utils.d.ts +74 -2
  84. package/dist/providers/utils.d.ts.map +1 -1
  85. package/dist/providers/utils.js +184 -3
  86. package/dist/providers/utils.js.map +1 -1
  87. package/dist/registry/default-pricing.d.ts +22 -0
  88. package/dist/registry/default-pricing.d.ts.map +1 -1
  89. package/dist/registry/default-pricing.js +162 -28
  90. package/dist/registry/default-pricing.js.map +1 -1
  91. package/dist/types/content.d.ts +13 -0
  92. package/dist/types/content.d.ts.map +1 -1
  93. package/dist/types/content.js.map +1 -1
  94. package/dist/types/errors.d.ts +15 -0
  95. package/dist/types/errors.d.ts.map +1 -1
  96. package/dist/types/errors.js +26 -0
  97. package/dist/types/errors.js.map +1 -1
  98. package/dist/types/index.d.ts +3 -3
  99. package/dist/types/index.d.ts.map +1 -1
  100. package/dist/types/index.js +1 -1
  101. package/dist/types/index.js.map +1 -1
  102. package/dist/types/provider.d.ts +50 -0
  103. package/dist/types/provider.d.ts.map +1 -1
  104. package/dist/types/request.d.ts +6 -0
  105. package/dist/types/request.d.ts.map +1 -1
  106. package/dist/types/response.d.ts +85 -4
  107. package/dist/types/response.d.ts.map +1 -1
  108. package/dist/types/response.js.map +1 -1
  109. package/dist/types/streaming.d.ts +6 -1
  110. package/dist/types/streaming.d.ts.map +1 -1
  111. package/dist/utils/cache-marker-budget.d.ts +91 -0
  112. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  113. package/dist/utils/cache-marker-budget.js +208 -0
  114. package/dist/utils/cache-marker-budget.js.map +1 -0
  115. package/dist/utils/cost.d.ts +22 -0
  116. package/dist/utils/cost.d.ts.map +1 -1
  117. package/dist/utils/cost.js +55 -0
  118. package/dist/utils/cost.js.map +1 -1
  119. package/dist/utils/thinking-carriers.d.ts +80 -0
  120. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  121. package/dist/utils/thinking-carriers.js +115 -0
  122. package/dist/utils/thinking-carriers.js.map +1 -0
  123. package/dist/utils/tool-parser.d.ts +24 -0
  124. package/dist/utils/tool-parser.d.ts.map +1 -1
  125. package/dist/utils/tool-parser.js +367 -161
  126. package/dist/utils/tool-parser.js.map +1 -1
  127. package/dist/utils/usage.d.ts +71 -0
  128. package/dist/utils/usage.d.ts.map +1 -0
  129. package/dist/utils/usage.js +161 -0
  130. package/dist/utils/usage.js.map +1 -0
  131. package/dist/yielding-stream.d.ts +8 -0
  132. package/dist/yielding-stream.d.ts.map +1 -1
  133. package/dist/yielding-stream.js +49 -3
  134. package/dist/yielding-stream.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/cache-wire-receipt.ts +125 -0
  137. package/src/context/index.ts +1 -0
  138. package/src/context/process.ts +305 -59
  139. package/src/context/types.ts +38 -0
  140. package/src/floating-cache-marker.test.ts +24 -0
  141. package/src/formatters/anthropic-xml.ts +66 -23
  142. package/src/formatters/native.ts +23 -2
  143. package/src/formatters/normalize-tool-pairs.ts +49 -0
  144. package/src/formatters/types.ts +35 -0
  145. package/src/index.ts +6 -0
  146. package/src/membrane.ts +974 -313
  147. package/src/providers/anthropic.ts +173 -19
  148. package/src/providers/bedrock.ts +25 -1
  149. package/src/providers/gemini.ts +125 -87
  150. package/src/providers/index.ts +1 -0
  151. package/src/providers/mock.ts +3 -0
  152. package/src/providers/openai-compatible.ts +102 -45
  153. package/src/providers/openai-completions.ts +112 -46
  154. package/src/providers/openai-responses-api.ts +45 -15
  155. package/src/providers/openai-responses.ts +8 -1
  156. package/src/providers/openai.ts +95 -42
  157. package/src/providers/openrouter.ts +125 -63
  158. package/src/providers/utils.ts +227 -6
  159. package/src/registry/default-pricing.ts +165 -28
  160. package/src/types/content.ts +14 -0
  161. package/src/types/errors.ts +28 -0
  162. package/src/types/index.ts +6 -0
  163. package/src/types/provider.ts +55 -0
  164. package/src/types/request.ts +7 -0
  165. package/src/types/response.ts +109 -8
  166. package/src/types/streaming.ts +8 -2
  167. package/src/utils/cache-marker-budget.ts +237 -0
  168. package/src/utils/cost.ts +64 -0
  169. package/src/utils/thinking-carriers.ts +125 -0
  170. package/src/utils/tool-parser.ts +473 -174
  171. package/src/utils/usage.ts +184 -0
  172. package/src/yielding-stream.ts +54 -3
@@ -112,6 +112,9 @@ export type ToolMode =
112
112
  // ============================================================================
113
113
 
114
114
  export interface NormalizedRequest {
115
+ /** Receives the exact post-format/post-hook cache receipt immediately before
116
+ * network submission. Observability only; never forwarded to providers. */
117
+ onCacheWireReceipt?: (receipt: import('../cache-wire-receipt.js').CacheWireReceipt) => void;
115
118
  /**
116
119
  * Explicitly own the loss of old inline images when the serialized request
117
120
  * exceeds the API byte cap: oldest images are replaced with loud
@@ -152,6 +155,10 @@ export interface NormalizedRequest {
152
155
  */
153
156
  promptCaching?: boolean;
154
157
 
158
+ /** Marker ownership policy. `cm-owned` disables every formatter-generated
159
+ * system/context-prefix marker; only normalized message breakpoints survive. */
160
+ cacheMarkers?: 'membrane-system' | 'cm-owned';
161
+
155
162
  /**
156
163
  * Cache TTL for Anthropic prompt caching.
157
164
  * '5m' (default) = 5 minute TTL
@@ -30,20 +30,77 @@ export interface BasicUsage {
30
30
  outputTokens: number;
31
31
  }
32
32
 
33
- export interface DetailedUsage extends BasicUsage {
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
- /** Tokens used for thinking/reasoning */
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
- /** Number of retry attempts */
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
  }
@@ -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,237 @@
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
+ * Validate a caller-owned marker layout without changing it.
154
+ *
155
+ * In `cm-owned` mode the caller has deliberately allocated the complete
156
+ * breakpoint set. Silently stripping or reordering those markers would make
157
+ * the cache receipt describe a request that was never sent, so invalid
158
+ * layouts fail before submission instead of using the legacy repair clamp.
159
+ */
160
+ export function assertCacheMarkersWithinLimit(
161
+ surfaces: WireCacheSurfaces,
162
+ site: string
163
+ ): number {
164
+ const marked = collectMarkedBlocks(surfaces);
165
+ const invalidThinking = marked.filter(
166
+ (block) => block.type === 'thinking' || block.type === 'redacted_thinking'
167
+ ).length;
168
+ if (invalidThinking > 0) {
169
+ throw new Error(
170
+ `${site}: caller-owned cache_control cannot be attached to thinking/redacted_thinking blocks`
171
+ );
172
+ }
173
+ if (marked.length > MAX_CACHE_BREAKPOINTS) {
174
+ throw new Error(
175
+ `${site}: cache_control limit exceeded: ${marked.length} markers (maximum ${MAX_CACHE_BREAKPOINTS})`
176
+ );
177
+ }
178
+ return marked.length;
179
+ }
180
+
181
+ /**
182
+ * Bring a request inside the breakpoint budget, in place, at the last exit
183
+ * before the adapter call. Two repairs, both loud:
184
+ *
185
+ * 1. a marker riding a thinking / redacted_thinking block is stripped — the
186
+ * API rejects those outright ("thinking.cache_control: Extra inputs are
187
+ * not permitted"), and the three builders' `lastCacheableBlockIndex`
188
+ * discipline is only as good as its last caller. This is the runtime
189
+ * assertion; a fourth builder can no longer repeat that history.
190
+ * 2. markers past the limit are dropped SHALLOWEST-FIRST, keeping the 4
191
+ * deepest — the deepest marker caches the longest prefix, and every
192
+ * shallower prefix it subsumes.
193
+ *
194
+ * Dropping loudly beats a 400: the request still ships, cached less than the
195
+ * caller asked for, with the overspend named.
196
+ */
197
+ export function clampCacheMarkers(
198
+ surfaces: WireCacheSurfaces,
199
+ site: string
200
+ ): { total: number; dropped: number; strippedFromThinking: number } {
201
+ const marked = collectMarkedBlocks(surfaces);
202
+
203
+ let strippedFromThinking = 0;
204
+ const cacheable: Array<Record<string, unknown>> = [];
205
+ for (const block of marked) {
206
+ if (block.type === 'thinking' || block.type === 'redacted_thinking') {
207
+ delete block.cache_control;
208
+ strippedFromThinking++;
209
+ continue;
210
+ }
211
+ cacheable.push(block);
212
+ }
213
+
214
+ let dropped = 0;
215
+ if (cacheable.length > MAX_CACHE_BREAKPOINTS) {
216
+ for (const block of cacheable.slice(0, cacheable.length - MAX_CACHE_BREAKPOINTS)) {
217
+ delete block.cache_control;
218
+ dropped++;
219
+ }
220
+ }
221
+
222
+ if (strippedFromThinking > 0) {
223
+ console.warn(
224
+ `[membrane] ${site}: stripped ${strippedFromThinking} cache_control marker(s) from ` +
225
+ `thinking blocks — the API rejects cache_control on thinking/redacted_thinking.`
226
+ );
227
+ }
228
+ if (dropped > 0) {
229
+ console.warn(
230
+ `[membrane] ${site}: ${cacheable.length} cache_control markers exceed the limit of ` +
231
+ `${MAX_CACHE_BREAKPOINTS} — dropped the ${dropped} shallowest and kept the deepest ` +
232
+ `${MAX_CACHE_BREAKPOINTS}. The request would otherwise have been rejected outright.`
233
+ );
234
+ }
235
+
236
+ return { total: cacheable.length - dropped, dropped, strippedFromThinking };
237
+ }
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
+ }