@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
@@ -1,3 +1,180 @@
1
+ import { TimeoutAbortError, authError, networkError, rateLimitError, serverError } from '../types/errors.js';
2
+
3
+ interface StreamErrorFrameFields {
4
+ code?: unknown;
5
+ status?: unknown;
6
+ type?: unknown;
7
+ message?: unknown;
8
+ retry_after?: unknown;
9
+ retryAfter?: unknown;
10
+ retry_after_ms?: unknown;
11
+ retryAfterMs?: unknown;
12
+ retryDelay?: unknown;
13
+ retry_delay?: unknown;
14
+ }
15
+
16
+ const RATE_LIMIT_FRAME_TOKENS = ['rate_limit', 'rate-limit', 'ratelimit', 'too_many_requests', 'resource_exhausted'];
17
+ const OVERLOADED_FRAME_TOKENS = ['overloaded'];
18
+ const SERVER_FRAME_TOKENS = ['server_error', 'internal', 'unavailable'];
19
+ const AUTH_FRAME_TOKENS = [
20
+ 'invalid_api_key',
21
+ 'api_key_invalid',
22
+ 'authentication_error',
23
+ 'unauthenticated',
24
+ 'permission_denied',
25
+ 'permission_error',
26
+ ];
27
+
28
+ function readNumericField(...candidates: unknown[]): number | undefined {
29
+ for (const candidate of candidates) {
30
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
31
+ if (typeof candidate === 'string' && /^\d+$/.test(candidate.trim())) return Number(candidate.trim());
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ function readDurationSeconds(...candidates: unknown[]): number | undefined {
37
+ for (const candidate of candidates) {
38
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
39
+ if (typeof candidate === 'string') {
40
+ const duration = candidate.trim().match(/^(\d+(?:\.\d+)?)s?$/);
41
+ if (duration?.[1]) return Number(duration[1]);
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function readFrameRetryAfterMs(fields: StreamErrorFrameFields): number | undefined {
48
+ const explicitMilliseconds = readNumericField(fields.retry_after_ms, fields.retryAfterMs);
49
+ if (explicitMilliseconds !== undefined) return explicitMilliseconds;
50
+
51
+ const seconds = readDurationSeconds(fields.retry_after, fields.retryAfter, fields.retryDelay, fields.retry_delay);
52
+ return seconds === undefined ? undefined : Math.round(seconds * 1000);
53
+ }
54
+
55
+ /**
56
+ * The frame's own non-numeric `type`/`status` values — the provider's
57
+ * structured classification tokens (`rate_limit_error`, `RESOURCE_EXHAUSTED`,
58
+ * `overloaded_error`), never free prose. Matching a substring against these is
59
+ * safe in a way that matching the same substring against a human-readable
60
+ * message is not.
61
+ */
62
+ function readFrameClassificationTokens(fields: StreamErrorFrameFields): string[] {
63
+ return [fields.type, fields.status, fields.code]
64
+ .filter((value): value is string => typeof value === 'string' && value.trim() !== '')
65
+ .map((value) => value.trim())
66
+ .filter((token) => !/^\d+$/.test(token));
67
+ }
68
+
69
+ function frameTokensMatch(tokens: string[], needles: string[]): boolean {
70
+ return tokens.some((token) => needles.some((needle) => token.toLowerCase().includes(needle)));
71
+ }
72
+
73
+ /**
74
+ * Throw when an SSE data frame carries a provider `error` payload.
75
+ *
76
+ * Every provider delivers mid-stream failures (upstream 429s, capacity loss,
77
+ * safety aborts) as a data line with an `error` object inside an HTTP-200
78
+ * stream. A loop that only looks for `choices`/`candidates` drops that frame,
79
+ * reaches EOF, and builds a well-formed success out of whatever arrived first:
80
+ * partial content, the initialised default finish reason, zero usage. Nothing
81
+ * distinguishes it from the model choosing to stop, so the truncated turn is
82
+ * persisted as a real one.
83
+ *
84
+ * The house ruled this a bug twice before it was hoisted here — once in
85
+ * openrouter.ts (tests/unit/openrouter-stream-error.test.ts) and once in
86
+ * openai-responses-api.ts — and both times the fix stayed in the one adapter
87
+ * that was being touched. This is the single site every SSE adapter calls.
88
+ *
89
+ * The frame is CLASSIFIED here rather than downstream. Throwing a bare
90
+ * `Error(string)` left `code`/`type`/`status` as prose, and each adapter's
91
+ * `handleError` then re-derived a category by substring-matching that prose —
92
+ * so a mid-stream 429 arriving as `status: 429` (no literal "429" in the
93
+ * message) normalized to `unknown, retryable: false` and suppressed the retry
94
+ * the provider was explicitly asking for. Classified failures reach the caller
95
+ * intact because every adapter's `handleError` returns a MembraneError
96
+ * unchanged. Mapping, using the structured fields only:
97
+ * - 429, or a rate-limit-shaped token -> rate_limit (retryable), carrying
98
+ * the frame's retry hint when it has one
99
+ * - 5xx, or an overloaded/server-shaped token -> server (retryable);
100
+ * overloaded-without-a-status takes 529 so it lands on the capacity
101
+ * backoff schedule, matching how anthropic.ts recovers the same shape
102
+ * - 401/403, or an auth-shaped token -> auth (non-retryable)
103
+ * - anything else -> the previous bare Error, so the adapter's own
104
+ * provider-specific fallbacks still get their swing at it
105
+ * The provider's message text is never dropped, and the raw frame plus the
106
+ * request ride along on the classified error.
107
+ *
108
+ * `errorNoun` names what carried the payload. It defaults to the SSE case, and
109
+ * exists because the same `{ error: { code, message } }` object also arrives on
110
+ * a non-streaming 200 body, where calling the failure a stream error would be
111
+ * false. Classification reads the payload's own fields either way — the
112
+ * transport was never part of the rule.
113
+ */
114
+ export function throwOnStreamErrorFrame(
115
+ parsed: unknown,
116
+ providerLabel: string,
117
+ rawRequest?: unknown,
118
+ errorNoun: string = 'stream error'
119
+ ): void {
120
+ if (typeof parsed !== 'object' || parsed === null) return;
121
+ const streamError = (parsed as { error?: unknown }).error;
122
+ if (!streamError) return;
123
+
124
+ const fields: StreamErrorFrameFields =
125
+ typeof streamError === 'object' ? (streamError as StreamErrorFrameFields) : {};
126
+ const httpStatus = readNumericField(fields.code, fields.status);
127
+ const tokens = readFrameClassificationTokens(fields);
128
+ const providerMessage =
129
+ typeof fields.message === 'string' && fields.message !== ''
130
+ ? fields.message
131
+ : JSON.stringify(streamError);
132
+
133
+ const description =
134
+ `${providerLabel} ${errorNoun}` +
135
+ `${httpStatus !== undefined ? ` (${httpStatus})` : ''}` +
136
+ `${tokens.length > 0 ? ` [${tokens.join(' ')}]` : ''}: ${providerMessage}`;
137
+
138
+ if (httpStatus === 429 || frameTokensMatch(tokens, RATE_LIMIT_FRAME_TOKENS)) {
139
+ throw rateLimitError(description, readFrameRetryAfterMs(fields), parsed, rawRequest);
140
+ }
141
+
142
+ if (httpStatus === 401 || httpStatus === 403 || frameTokensMatch(tokens, AUTH_FRAME_TOKENS)) {
143
+ throw authError(description, parsed, rawRequest);
144
+ }
145
+
146
+ const overloadedShaped = frameTokensMatch(tokens, OVERLOADED_FRAME_TOKENS);
147
+ if ((httpStatus !== undefined && httpStatus >= 500) || overloadedShaped || frameTokensMatch(tokens, SERVER_FRAME_TOKENS)) {
148
+ throw serverError(description, httpStatus ?? (overloadedShaped ? 529 : undefined), parsed, rawRequest);
149
+ }
150
+
151
+ throw new Error(description);
152
+ }
153
+
154
+ /**
155
+ * Throw when a stream reached EOF without ever observing a terminal event.
156
+ *
157
+ * The terminal signal (`finish_reason`, `[DONE]`, Anthropic's `message_delta`,
158
+ * Gemini's `finishReason`) must be an OBSERVATION, not a default. A graceful
159
+ * upstream close with no terminal frame — proxy/LB idle timeout, early FIN,
160
+ * a gateway truncating the body — otherwise yields a clean-looking
161
+ * `end_turn` over partial content plus a fabricated `finish_reason` that never
162
+ * came off the wire. Abrupt resets already reject the read; graceful ones did
163
+ * not. Retryable by construction: the request was never answered in full.
164
+ */
165
+ export function assertTerminalEventObserved(
166
+ sawTerminalEvent: boolean,
167
+ providerLabel: string,
168
+ rawRequest?: unknown
169
+ ): void {
170
+ if (sawTerminalEvent) return;
171
+ throw networkError(
172
+ `${providerLabel} stream ended before a terminal event (connection dropped mid-stream)`,
173
+ undefined,
174
+ rawRequest
175
+ );
176
+ }
177
+
1
178
  /**
2
179
  * Safely parse a JSON string, returning an empty object on failure.
3
180
  * Used for tool call arguments which may be malformed from streaming.
@@ -11,6 +188,52 @@ export function safeParseJson(str: string | undefined): Record<string, unknown>
11
188
  }
12
189
  }
13
190
 
191
+ /**
192
+ * Marks the abort reason raised by an adapter's own `timeoutMs` deadline, so
193
+ * the error that comes back out of `fetch` can be told apart from a caller's
194
+ * cancellation by PROVENANCE rather than by matching its message text.
195
+ *
196
+ * `fetch` rejects with the signal's own `reason` object — identity and extra
197
+ * properties intact, on both the pre-headers and the body-read paths — so the
198
+ * mark survives the round trip through the platform. When some layer does
199
+ * replace the error, `isDeadlineAbort` simply reports false and the abort
200
+ * classifies as it did before: a missing mark degrades to the old answer, it
201
+ * never invents a timeout.
202
+ */
203
+ const DEADLINE_ABORT = Symbol.for('membrane.deadlineAbort');
204
+
205
+ function deadlineAbortReason(): DOMException {
206
+ const reason = new DOMException('Request timed out', 'AbortError');
207
+ Object.defineProperty(reason, DEADLINE_ABORT, { value: true, enumerable: false });
208
+ return reason;
209
+ }
210
+
211
+ /**
212
+ * True when this error is the abort raised by an adapter's own deadline.
213
+ *
214
+ * A caller's cancellation never carries the mark (the combined controller is
215
+ * aborted with the caller's own reason), and if both race, whichever fired
216
+ * first is the reason the platform rejects with — so the mark answers "was
217
+ * this OUR deadline?" without a separate tie-break.
218
+ */
219
+ export function isDeadlineAbort(error: unknown): boolean {
220
+ return (
221
+ typeof error === 'object' &&
222
+ error !== null &&
223
+ (error as Record<symbol, unknown>)[DEADLINE_ABORT] === true
224
+ );
225
+ }
226
+
227
+ /**
228
+ * The typed error a deadline abort should become: a timeout by classification,
229
+ * an abort by provenance. Adapters return this from `handleError` instead of a
230
+ * bare `abortError()`, which used to erase the distinction before Membrane's
231
+ * caller-signal > timeout > error ladder could read it.
232
+ */
233
+ export function deadlineTimeoutError(error: unknown, rawRequest?: unknown): TimeoutAbortError {
234
+ return new TimeoutAbortError('Request timed out', error, rawRequest);
235
+ }
236
+
14
237
  /**
15
238
  * Create a combined AbortSignal that fires on either the caller's signal
16
239
  * or a timeout (whichever comes first).
@@ -18,8 +241,9 @@ export function safeParseJson(str: string | undefined): Record<string, unknown>
18
241
  * The returned `cleanup` function MUST be called in a `finally` block to
19
242
  * clear the timeout and remove the event listener, preventing leaks.
20
243
  *
21
- * Timeout aborts with `DOMException('Request timed out', 'AbortError')`
22
- * so it classifies identically to user-initiated aborts.
244
+ * Timeout aborts with a marked `DOMException('Request timed out',
245
+ * 'AbortError')`: abort-shaped like any cancellation, and identifiable as the
246
+ * deadline's doing via `isDeadlineAbort`.
23
247
  */
24
248
  export function createCombinedSignal(
25
249
  signal?: AbortSignal,
@@ -32,10 +256,7 @@ export function createCombinedSignal(
32
256
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
33
257
 
34
258
  if (timeoutMs) {
35
- timeoutId = setTimeout(
36
- () => controller.abort(new DOMException('Request timed out', 'AbortError')),
37
- timeoutMs
38
- );
259
+ timeoutId = setTimeout(() => controller.abort(deadlineAbortReason()), timeoutMs);
39
260
  }
40
261
 
41
262
  const onAbort = () => controller.abort(signal!.reason);
@@ -1,66 +1,203 @@
1
1
  import type { ModelPricing } from '../types/provider.js';
2
2
 
3
3
  /**
4
- * Built-in pricing table for known models.
5
- * Prices in USD per million tokens. Last updated: 2025-07.
4
+ * Built-in pricing table for known models, in USD per million tokens.
5
+ * Used as fallback when no ModelRegistry is configured; registry pricing
6
+ * (if available) takes precedence.
6
7
  *
7
- * Used as fallback when no ModelRegistry is configured.
8
- * Registry pricing (if available) takes precedence.
8
+ * Every row carries its OWN verification date through `ModelPricing.asOf`, out
9
+ * to `CostBreakdown.pricingAsOf`, so a caller can see how old the number it is
10
+ * billing against is instead of trusting a prose header. Currently-published
11
+ * rows were read off the provider's own price page on the date in
12
+ * {@link DEFAULT_PRICING_LAST_VERIFIED} (the per-section comments name the
13
+ * page) and carry it. A row for a RETIRED model carries the date its rate was
14
+ * last published instead — claude-3-5-sonnet is dated 2025-07-01 because it is
15
+ * no longer listed anywhere to verify against, and stamping it with the sweep
16
+ * date would claim a check that cannot be performed. The previous header
17
+ * claimed "Last updated: 2025-07" for the whole table while it carried Claude
18
+ * 4.6 rows, and three rows were simply wrong by the time they were checked.
19
+ *
20
+ * Matching is longest-prefix (see {@link getDefaultPricing}), so a more
21
+ * specific row always wins over a more general one and rows may be listed in
22
+ * any order.
9
23
  */
24
+ export const DEFAULT_PRICING_LAST_VERIFIED = '2026-08-25';
25
+
10
26
  const PRICING_TABLE: Array<{ prefix: string; pricing: ModelPricing }> = [
11
- // Anthropic — Claude 4.6
27
+ // --------------------------------------------------------------------------
28
+ // Anthropic — https://docs.claude.com/en/docs/about-claude/pricing
29
+ // Retrieved 2026-08-25. Cache-write column is the 5-minute TTL bucket; the
30
+ // 1-hour bucket costs more and this table has no field for it.
31
+ // --------------------------------------------------------------------------
12
32
  {
13
- prefix: 'claude-opus-4-6',
14
- pricing: { inputPerMillion: 15, outputPerMillion: 75, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.50, currency: 'USD' },
33
+ prefix: 'claude-fable-5',
34
+ pricing: { inputPerMillion: 10, outputPerMillion: 50, cacheWritePerMillion: 12.50, cacheReadPerMillion: 1, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
15
35
  },
16
36
  {
17
- prefix: 'claude-sonnet-4-6',
18
- pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD' },
37
+ prefix: 'claude-opus-5',
38
+ pricing: { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
19
39
  },
20
- // Anthropic — Claude 4.5
21
40
  {
22
- prefix: 'claude-haiku-4-5',
23
- pricing: { inputPerMillion: 0.80, outputPerMillion: 4, cacheWritePerMillion: 1.00, cacheReadPerMillion: 0.08, currency: 'USD' },
41
+ prefix: 'claude-sonnet-5',
42
+ pricing: { inputPerMillion: 2, outputPerMillion: 10, cacheWritePerMillion: 2.50, cacheReadPerMillion: 0.20, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
43
+ },
44
+ // Opus 4.5 through 4.8 all price identically to Opus 5.
45
+ {
46
+ prefix: 'claude-opus-4-8',
47
+ pricing: { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
24
48
  },
25
- // Anthropic — Claude 4
26
49
  {
50
+ prefix: 'claude-opus-4-7',
51
+ pricing: { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
52
+ },
53
+ {
54
+ // Was priced here at 15/75 — the retired Opus 4 rate, 3x the real one.
55
+ prefix: 'claude-opus-4-6',
56
+ pricing: { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
57
+ },
58
+ {
59
+ prefix: 'claude-opus-4-5',
60
+ pricing: { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
61
+ },
62
+ {
63
+ // Retired except on Bedrock and Google Cloud; also covers Opus 4.1, which
64
+ // is priced identically.
27
65
  prefix: 'claude-opus-4',
28
- pricing: { inputPerMillion: 15, outputPerMillion: 75, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.50, currency: 'USD' },
66
+ pricing: { inputPerMillion: 15, outputPerMillion: 75, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
29
67
  },
30
68
  {
69
+ prefix: 'claude-sonnet-4-6',
70
+ pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
71
+ },
72
+ {
73
+ prefix: 'claude-sonnet-4-5',
74
+ pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
75
+ },
76
+ {
77
+ // Retired except on Bedrock and Google Cloud.
31
78
  prefix: 'claude-sonnet-4',
32
- pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD' },
79
+ pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
33
80
  },
34
- // Anthropic — Claude 3.5
35
81
  {
36
- prefix: 'claude-3-5-sonnet',
37
- pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD' },
82
+ // Was priced here at 0.80/4 — the retired Haiku 3.5 rate.
83
+ prefix: 'claude-haiku-4-5',
84
+ pricing: { inputPerMillion: 1, outputPerMillion: 5, cacheWritePerMillion: 1.25, cacheReadPerMillion: 0.10, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
38
85
  },
39
86
  {
87
+ // Retired except on Bedrock and Google Cloud.
40
88
  prefix: 'claude-3-5-haiku',
41
- pricing: { inputPerMillion: 0.80, outputPerMillion: 4, cacheWritePerMillion: 1.00, cacheReadPerMillion: 0.08, currency: 'USD' },
89
+ pricing: { inputPerMillion: 0.80, outputPerMillion: 4, cacheWritePerMillion: 1, cacheReadPerMillion: 0.08, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
90
+ },
91
+ {
92
+ // Fully retired: no longer listed on the price page as of 2026-08-25, so
93
+ // this is the last published rate rather than a current one.
94
+ prefix: 'claude-3-5-sonnet',
95
+ pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30, currency: 'USD', asOf: '2025-07-01' },
42
96
  },
43
- // OpenAI — GPT-4o
97
+
98
+ // --------------------------------------------------------------------------
99
+ // OpenAI — https://platform.openai.com/docs/pricing (standard tier)
100
+ // Retrieved 2026-08-25. "Cached input" is OpenAI's discounted rate for the
101
+ // automatically-cached prompt prefix; note that OpenAI's prompt_tokens
102
+ // INCLUDES that span (see UsageCacheConvention), which membrane normalizes
103
+ // away before pricing, so cacheReadPerMillion is applied to a disjoint count.
104
+ // --------------------------------------------------------------------------
44
105
  {
45
- prefix: 'gpt-4o-2024',
46
- pricing: { inputPerMillion: 2.50, outputPerMillion: 10, cacheReadPerMillion: 1.25, currency: 'USD' },
106
+ prefix: 'gpt-5.6-sol',
107
+ pricing: { inputPerMillion: 4, outputPerMillion: 20, cacheReadPerMillion: 0.40, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
47
108
  },
48
109
  {
49
- prefix: 'gpt-4o',
50
- pricing: { inputPerMillion: 2.50, outputPerMillion: 10, cacheReadPerMillion: 1.25, currency: 'USD' },
110
+ prefix: 'gpt-5.6-terra',
111
+ pricing: { inputPerMillion: 2, outputPerMillion: 12, cacheReadPerMillion: 0.20, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
112
+ },
113
+ {
114
+ prefix: 'gpt-5.6-luna',
115
+ pricing: { inputPerMillion: 0.20, outputPerMillion: 1.20, cacheReadPerMillion: 0.02, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
116
+ },
117
+ {
118
+ // Under 272K context; the long-context tier costs more and this table has
119
+ // no field for context-dependent rates.
120
+ prefix: 'gpt-5.5',
121
+ pricing: { inputPerMillion: 5, outputPerMillion: 30, cacheReadPerMillion: 0.50, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
122
+ },
123
+ {
124
+ prefix: 'gpt-5.4-mini',
125
+ pricing: { inputPerMillion: 0.75, outputPerMillion: 4.50, cacheReadPerMillion: 0.075, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
126
+ },
127
+ {
128
+ prefix: 'gpt-5.4-nano',
129
+ pricing: { inputPerMillion: 0.20, outputPerMillion: 1.25, cacheReadPerMillion: 0.02, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
130
+ },
131
+ {
132
+ // Under 272K context.
133
+ prefix: 'gpt-5.4',
134
+ pricing: { inputPerMillion: 2.50, outputPerMillion: 15, cacheReadPerMillion: 0.25, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
135
+ },
136
+ {
137
+ prefix: 'gpt-5.2',
138
+ pricing: { inputPerMillion: 1.75, outputPerMillion: 14, cacheReadPerMillion: 0.175, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
139
+ },
140
+ {
141
+ prefix: 'gpt-5.1',
142
+ pricing: { inputPerMillion: 1.25, outputPerMillion: 10, cacheReadPerMillion: 0.125, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
143
+ },
144
+ {
145
+ prefix: 'gpt-5-mini',
146
+ pricing: { inputPerMillion: 0.25, outputPerMillion: 2, cacheReadPerMillion: 0.025, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
147
+ },
148
+ {
149
+ prefix: 'gpt-5-nano',
150
+ pricing: { inputPerMillion: 0.05, outputPerMillion: 0.40, cacheReadPerMillion: 0.005, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
151
+ },
152
+ {
153
+ prefix: 'gpt-5',
154
+ pricing: { inputPerMillion: 1.25, outputPerMillion: 10, cacheReadPerMillion: 0.125, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
51
155
  },
52
156
  {
53
157
  prefix: 'gpt-4o-mini',
54
- pricing: { inputPerMillion: 0.15, outputPerMillion: 0.60, cacheReadPerMillion: 0.075, currency: 'USD' },
158
+ pricing: { inputPerMillion: 0.15, outputPerMillion: 0.60, cacheReadPerMillion: 0.075, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
159
+ },
160
+ {
161
+ // The one gpt-4o snapshot priced differently from plain gpt-4o. Replaces a
162
+ // `gpt-4o-2024` row that was byte-identical to gpt-4o and so could never
163
+ // change any answer — while masking this genuine difference.
164
+ prefix: 'gpt-4o-2024-05-13',
165
+ pricing: { inputPerMillion: 5, outputPerMillion: 15, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
166
+ },
167
+ {
168
+ prefix: 'gpt-4o',
169
+ pricing: { inputPerMillion: 2.50, outputPerMillion: 10, cacheReadPerMillion: 1.25, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
170
+ },
171
+
172
+ // --------------------------------------------------------------------------
173
+ // Google — https://ai.google.dev/gemini-api/docs/pricing (paid tier)
174
+ // Retrieved 2026-08-25. Google's published output price INCLUDES thinking
175
+ // tokens, which is why the gemini adapter folds thoughtsTokenCount into
176
+ // outputTokens. Cache rates are the context-caching per-token price and
177
+ // exclude Google's separate per-hour storage charge, which membrane does not
178
+ // model. Where a model has a >200k-token tier the smaller-prompt rate is used.
179
+ // --------------------------------------------------------------------------
180
+ {
181
+ prefix: 'gemini-3.5-flash-lite',
182
+ pricing: { inputPerMillion: 0.30, outputPerMillion: 2.50, cacheReadPerMillion: 0.03, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
183
+ },
184
+ {
185
+ prefix: 'gemini-3.5-flash',
186
+ pricing: { inputPerMillion: 1.50, outputPerMillion: 9, cacheReadPerMillion: 0.15, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
55
187
  },
56
- // Google — Gemini 2.5
57
188
  {
58
189
  prefix: 'gemini-2.5-pro',
59
- pricing: { inputPerMillion: 1.25, outputPerMillion: 10, currency: 'USD' },
190
+ pricing: { inputPerMillion: 1.25, outputPerMillion: 10, cacheReadPerMillion: 0.125, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
191
+ },
192
+ {
193
+ // Was absent, so the gemini-2.5-flash row priced it at 3x input / 6x output.
194
+ prefix: 'gemini-2.5-flash-lite',
195
+ pricing: { inputPerMillion: 0.10, outputPerMillion: 0.40, cacheReadPerMillion: 0.01, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
60
196
  },
61
197
  {
198
+ // Was priced here at 0.15/0.60 — text rate; audio input costs more.
62
199
  prefix: 'gemini-2.5-flash',
63
- pricing: { inputPerMillion: 0.15, outputPerMillion: 0.60, currency: 'USD' },
200
+ pricing: { inputPerMillion: 0.30, outputPerMillion: 2.50, cacheReadPerMillion: 0.03, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
64
201
  },
65
202
  ];
66
203
 
@@ -121,6 +121,20 @@ export interface ToolUseContent {
121
121
  * block. Absent on native-tools blocks and on legacy stored blocks.
122
122
  */
123
123
  rawXml?: string;
124
+ /**
125
+ * Raw accumulated argument text that FAILED to parse as JSON, kept verbatim.
126
+ *
127
+ * Present only when the provider's streamed `input_json_delta` fragments did
128
+ * not assemble into valid JSON — a call truncated mid-arguments (max_tokens)
129
+ * is the usual cause. `input` then holds whatever the provider's
130
+ * content_block_start carried, which for Anthropic is `{}`: a wire-valid
131
+ * tool call with empty arguments, indistinguishable from a genuine no-arg
132
+ * call once it is written to durable history. Presence of this field means
133
+ * `input` is NOT the model's arguments, so a consumer can refuse the block
134
+ * (or attempt its own repair) instead of trusting a plausible `{}`.
135
+ */
136
+ unparseableInput?: string;
137
+
124
138
  /** See {@link TextContent.rawItem}. */
125
139
  rawItem?: unknown;
126
140
  }
@@ -215,6 +215,34 @@ export function abortError(message: string = 'Request was aborted', rawRequest?:
215
215
  });
216
216
  }
217
217
 
218
+ /**
219
+ * A request cancelled by the adapter's OWN deadline (`timeoutMs`), as opposed
220
+ * to a caller's signal or a stray abort.
221
+ *
222
+ * It is both facts at once, and callers need both: a timeout by `type` (so
223
+ * `classifyError` and the abort-reason ladder report `'timeout'`), and an
224
+ * abort by provenance (so the streaming paths still hand back an
225
+ * `AbortedResponse` with whatever partial content arrived, rather than
226
+ * throwing). Non-retryable: the deadline that fired belongs to this call, and
227
+ * retrying inside it would only spend the caller's budget again.
228
+ */
229
+ export class TimeoutAbortError extends MembraneError {
230
+ constructor(message: string = 'Request timed out', raw?: unknown, rawRequest?: unknown) {
231
+ super({
232
+ type: 'timeout',
233
+ message,
234
+ retryable: false,
235
+ rawError: raw,
236
+ rawRequest,
237
+ });
238
+ this.name = 'TimeoutAbortError';
239
+ }
240
+ }
241
+
242
+ export function isTimeoutAbortError(error: unknown): error is TimeoutAbortError {
243
+ return error instanceof TimeoutAbortError;
244
+ }
245
+
218
246
  export function safetyError(message: string, raw?: unknown, rawRequest?: unknown): MembraneError {
219
247
  return new MembraneError({
220
248
  type: 'safety',
@@ -73,7 +73,10 @@ export type {
73
73
  export type {
74
74
  StopReason,
75
75
  BasicUsage,
76
+ CallUsage,
76
77
  DetailedUsage,
78
+ TurnRoundUsage,
79
+ DiscardedAttemptsUsage,
77
80
  CostBreakdown,
78
81
  StopInfo,
79
82
  ModelInfo,
@@ -101,6 +104,7 @@ export type {
101
104
  ProviderRequestOptions,
102
105
  ProviderResponse,
103
106
  StreamCallbacks,
107
+ UsageCacheConvention,
104
108
  } from './provider.js';
105
109
 
106
110
  // Streaming
@@ -162,6 +166,8 @@ export {
162
166
  networkError,
163
167
  timeoutError,
164
168
  abortError,
169
+ TimeoutAbortError,
170
+ isTimeoutAbortError,
165
171
  safetyError,
166
172
  unsupportedError,
167
173
  classifyError,
@@ -82,6 +82,21 @@ export interface ProviderCapabilities {
82
82
  // Model Pricing
83
83
  // ============================================================================
84
84
 
85
+ /**
86
+ * Whether a provider's prompt-token count INCLUDES the span served from cache.
87
+ *
88
+ * Measured live 2026-08-25 — Anthropic (`cache-excluded`): a 4,650-token cached
89
+ * system prompt returned `input_tokens: 8` with `cache_read_input_tokens: 4650`.
90
+ * OpenAI (`cache-inclusive`): `prompt_tokens` stayed at 1732 across a cache hit
91
+ * that reported `cached_tokens: 1664`, so cached is a SUBSET of the prompt.
92
+ *
93
+ * `unknown` is a real epistemic state, not a default to lean on: it means no
94
+ * one has established this adapter's convention, and membrane will pass the
95
+ * counts through unchanged and warn the first time a cache read makes the
96
+ * ambiguity bite.
97
+ */
98
+ export type UsageCacheConvention = 'cache-excluded' | 'cache-inclusive' | 'unknown';
99
+
85
100
  export interface ModelPricing {
86
101
  /** Cost per million input tokens */
87
102
  inputPerMillion: number;
@@ -97,6 +112,14 @@ export interface ModelPricing {
97
112
 
98
113
  /** Currency code */
99
114
  currency: string;
115
+
116
+ /**
117
+ * ISO date these rates were last checked against the provider's published
118
+ * price page, surfaced to callers as {@link CostBreakdown.pricingAsOf}. A
119
+ * pricing source that cannot vouch for a date leaves it unset — better an
120
+ * absent freshness signal than a fabricated one.
121
+ */
122
+ asOf?: string;
100
123
  }
101
124
 
102
125
  // ============================================================================
@@ -169,6 +192,21 @@ export interface ProviderAdapter {
169
192
  /** Provider name */
170
193
  readonly name: string;
171
194
 
195
+ /**
196
+ * Which convention this adapter's `usage.inputTokens` carries. Membrane
197
+ * normalizes every response onto `cache-excluded` before any ratio or cost is
198
+ * computed, and it can only do that if the adapter says what it is reporting.
199
+ *
200
+ * OPTIONAL, defaulting to `'unknown'`: an adapter that declares nothing is in
201
+ * exactly the state `'unknown'` names, and treating it that way — pass the
202
+ * counts through untouched, warn once when a cache read makes the ambiguity
203
+ * bite — is the honest reading of silence. Requiring it would also stop every
204
+ * external custom adapter compiling for a fact membrane can already say it
205
+ * does not know. Declare it: `'unknown'` is a real epistemic state, not a
206
+ * resting place.
207
+ */
208
+ usageCacheConvention?: UsageCacheConvention;
209
+
172
210
  /** Check if this adapter handles a model */
173
211
  supportsModel(modelId: string): boolean;
174
212
 
@@ -272,6 +310,23 @@ export interface ProviderResponse {
272
310
  outputTokens: number;
273
311
  cacheCreationTokens?: number;
274
312
  cacheReadTokens?: number;
313
+
314
+ /**
315
+ * Thinking/reasoning tokens reported separately from the visible-output
316
+ * count and already folded INTO `outputTokens`.
317
+ * See {@link DetailedUsage.thinkingTokens}.
318
+ */
319
+ thinkingTokens?: number;
320
+
321
+ /**
322
+ * Overrides {@link ProviderAdapter.usageCacheConvention} for THIS response.
323
+ * Needed where one adapter fronts several upstream conventions: OpenRouter
324
+ * reads `cache_read_input_tokens` (Anthropic, cache-excluded) OR
325
+ * `prompt_tokens_details.cached_tokens` (OpenAI, cache-inclusive) depending
326
+ * on which provider it routed to, so the convention is a per-response fact
327
+ * there rather than a per-adapter one.
328
+ */
329
+ cacheConvention?: UsageCacheConvention;
275
330
  };
276
331
 
277
332
  /** Model that actually ran */