@animalabs/membrane 0.5.81 → 0.5.83

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 (51) 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/formatters/anthropic-xml.d.ts.map +1 -1
  6. package/dist/formatters/anthropic-xml.js +9 -6
  7. package/dist/formatters/anthropic-xml.js.map +1 -1
  8. package/dist/formatters/native.d.ts.map +1 -1
  9. package/dist/formatters/native.js +16 -3
  10. package/dist/formatters/native.js.map +1 -1
  11. package/dist/formatters/types.d.ts +2 -0
  12. package/dist/formatters/types.d.ts.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/membrane.d.ts.map +1 -1
  18. package/dist/membrane.js +15 -4
  19. package/dist/membrane.js.map +1 -1
  20. package/dist/providers/anthropic.d.ts +12 -1
  21. package/dist/providers/anthropic.d.ts.map +1 -1
  22. package/dist/providers/anthropic.js +4 -4
  23. package/dist/providers/anthropic.js.map +1 -1
  24. package/dist/providers/index.d.ts +1 -1
  25. package/dist/providers/index.d.ts.map +1 -1
  26. package/dist/providers/index.js.map +1 -1
  27. package/dist/providers/openai.d.ts.map +1 -1
  28. package/dist/providers/openai.js +29 -13
  29. package/dist/providers/openai.js.map +1 -1
  30. package/dist/registry/default-pricing.d.ts.map +1 -1
  31. package/dist/registry/default-pricing.js +4 -0
  32. package/dist/registry/default-pricing.js.map +1 -1
  33. package/dist/types/request.d.ts +6 -0
  34. package/dist/types/request.d.ts.map +1 -1
  35. package/dist/utils/cache-marker-budget.d.ts +9 -0
  36. package/dist/utils/cache-marker-budget.d.ts.map +1 -1
  37. package/dist/utils/cache-marker-budget.js +19 -0
  38. package/dist/utils/cache-marker-budget.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cache-wire-receipt.ts +125 -0
  41. package/src/formatters/anthropic-xml.ts +9 -8
  42. package/src/formatters/native.ts +23 -2
  43. package/src/formatters/types.ts +3 -0
  44. package/src/index.ts +6 -0
  45. package/src/membrane.ts +14 -3
  46. package/src/providers/anthropic.ts +18 -6
  47. package/src/providers/index.ts +1 -0
  48. package/src/providers/openai.ts +31 -13
  49. package/src/registry/default-pricing.ts +4 -0
  50. package/src/types/request.ts +7 -0
  51. package/src/utils/cache-marker-budget.ts +29 -0
@@ -0,0 +1,125 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export interface CacheWireMarkerReceipt {
4
+ ordinal: number;
5
+ prefixHash: string;
6
+ estimatedOffset: number;
7
+ }
8
+
9
+ export interface CacheWireReceipt {
10
+ requestHash: string;
11
+ markers: CacheWireMarkerReceipt[];
12
+ }
13
+
14
+ /** Hash the exact post-format/post-hook provider request and every marked
15
+ * prefix. Token offsets are estimates; provider usage reconciles them later. */
16
+ export function computeCacheWireReceipt(rawRequest: unknown): CacheWireReceipt {
17
+ const requestHash = sha(stableStringify(rawRequest));
18
+ const blocks = flattenWireBlocks(rawRequest);
19
+ const prefix: unknown[] = [];
20
+ const markers: CacheWireMarkerReceipt[] = [];
21
+ let chars = 0;
22
+ for (const unit of blocks) {
23
+ prefix.push(unit.identity);
24
+ chars += unit.estimatedChars;
25
+ if (unit.marked) {
26
+ markers.push({
27
+ ordinal: markers.length,
28
+ prefixHash: sha(stableStringify(prefix)),
29
+ estimatedOffset: Math.ceil(chars / 4),
30
+ });
31
+ }
32
+ }
33
+ return { requestHash, markers };
34
+ }
35
+
36
+ interface ReceiptUnit {
37
+ identity: unknown;
38
+ marked: boolean;
39
+ estimatedChars: number;
40
+ }
41
+
42
+ function flattenWireBlocks(rawRequest: unknown): ReceiptUnit[] {
43
+ if (!rawRequest || typeof rawRequest !== 'object') {
44
+ return [{ identity: rawRequest, marked: false, estimatedChars: stableStringify(rawRequest).length }];
45
+ }
46
+ const request = rawRequest as Record<string, unknown>;
47
+ const out: ReceiptUnit[] = [];
48
+ if (Array.isArray(request.tools)) {
49
+ request.tools.forEach((tool, index) => out.push({
50
+ identity: { surface: 'tool', index, tool },
51
+ marked: hasCacheControl(tool),
52
+ estimatedChars: stableStringify(tool).length,
53
+ }));
54
+ } else if (request.tools !== undefined) {
55
+ out.push({
56
+ identity: { surface: 'tools', value: request.tools },
57
+ marked: hasCacheControl(request.tools),
58
+ estimatedChars: stableStringify(request.tools).length,
59
+ });
60
+ }
61
+ if (request.system !== undefined) {
62
+ const system = request.system;
63
+ if (Array.isArray(system)) {
64
+ system.forEach((block, index) => out.push({
65
+ identity: { surface: 'system', index, block },
66
+ marked: hasCacheControl(block),
67
+ estimatedChars: stableStringify(block).length,
68
+ }));
69
+ } else {
70
+ out.push({
71
+ identity: { surface: 'system', value: system },
72
+ marked: hasCacheControl(system),
73
+ estimatedChars: stableStringify(system).length,
74
+ });
75
+ }
76
+ }
77
+ if (Array.isArray(request.messages)) {
78
+ request.messages.forEach((message, messageIndex) => {
79
+ if (!message || typeof message !== 'object') {
80
+ out.push({
81
+ identity: { surface: 'message', messageIndex, value: message },
82
+ marked: false,
83
+ estimatedChars: stableStringify(message).length,
84
+ });
85
+ return;
86
+ }
87
+ const record = message as Record<string, unknown>;
88
+ const content = (message as Record<string, unknown>).content;
89
+ if (Array.isArray(content)) {
90
+ content.forEach((block, blockIndex) => out.push({
91
+ identity: {
92
+ surface: 'message-block', messageIndex, blockIndex,
93
+ role: record.role, block,
94
+ },
95
+ marked: hasCacheControl(block),
96
+ estimatedChars: stableStringify({ role: record.role, content: [block] }).length,
97
+ }));
98
+ } else {
99
+ out.push({
100
+ identity: { surface: 'message', messageIndex, role: record.role, content },
101
+ marked: hasCacheControl(message),
102
+ estimatedChars: stableStringify(message).length,
103
+ });
104
+ }
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function hasCacheControl(value: unknown): boolean {
111
+ return Boolean(value && typeof value === 'object' && (value as Record<string, unknown>).cache_control);
112
+ }
113
+
114
+ function stableStringify(value: unknown): string {
115
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
116
+ if (value && typeof value === 'object') {
117
+ const record = value as Record<string, unknown>;
118
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`;
119
+ }
120
+ return JSON.stringify(value) ?? 'null';
121
+ }
122
+
123
+ function sha(value: string): string {
124
+ return createHash('sha256').update(value).digest('hex');
125
+ }
@@ -34,7 +34,7 @@ import {
34
34
  type ToolDefinitionForPrompt,
35
35
  } from '../utils/tool-parser.js';
36
36
  import { IncrementalXmlParser } from '../utils/stream-parser.js';
37
- import { clampCacheMarkers } from '../utils/cache-marker-budget.js';
37
+ import { assertCacheMarkersWithinLimit, clampCacheMarkers } from '../utils/cache-marker-budget.js';
38
38
  import { lastCacheableBlockIndex } from './native.js';
39
39
  import { isAcceptedImageMediaType, strippedImagePlaceholder } from '../utils/image-media.js';
40
40
 
@@ -149,6 +149,7 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
149
149
  thinking,
150
150
  systemPrompt,
151
151
  promptCaching = false,
152
+ cacheMarkers = 'membrane-system',
152
153
  cacheTtl,
153
154
  contextPrefix,
154
155
  prefillUserMessage,
@@ -233,7 +234,7 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
233
234
  systemContent = systemBlocks;
234
235
  } else if (systemText) {
235
236
  const systemBlock: Record<string, unknown> = { type: 'text', text: systemText };
236
- if (promptCaching) {
237
+ if (promptCaching && cacheMarkers === 'membrane-system') {
237
238
  systemBlock.cache_control = cacheControl;
238
239
  }
239
240
  systemContent = [systemBlock];
@@ -242,7 +243,7 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
242
243
  // Add context prefix as first cached assistant message (for simulacrum seeding)
243
244
  if (contextPrefix) {
244
245
  const prefixBlock: Record<string, unknown> = { type: 'text', text: contextPrefix };
245
- if (promptCaching) {
246
+ if (promptCaching && cacheMarkers === 'membrane-system') {
246
247
  prefixBlock.cache_control = cacheControl;
247
248
  }
248
249
  providerMessages.push({
@@ -425,7 +426,7 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
425
426
  type: 'text',
426
427
  text: 'The assistant is in CLI simulation mode, and responds to the user\'s CLI commands only with the output of the command.',
427
428
  };
428
- if (promptCaching) {
429
+ if (promptCaching && cacheMarkers === 'membrane-system') {
429
430
  cliSystemBlock.cache_control = cacheControl;
430
431
  }
431
432
  systemContent = [cliSystemBlock];
@@ -457,10 +458,10 @@ export class AnthropicXmlFormatter implements PrefillFormatter {
457
458
  // limit, which rejects the request outright. Clamping here, once, on the
458
459
  // finished artifacts is the only count that can see all five sites; the
459
460
  // reported tally is that same recount, so it can never drift from the wire.
460
- const budget = clampCacheMarkers(
461
- { messages: providerMessages, system: systemContent, tools: nativeTools },
462
- 'anthropic-xml'
463
- );
461
+ const cacheSurfaces = { messages: providerMessages, system: systemContent, tools: nativeTools };
462
+ const budget = cacheMarkers === 'cm-owned'
463
+ ? { total: assertCacheMarkersWithinLimit(cacheSurfaces, 'anthropic-xml') }
464
+ : clampCacheMarkers(cacheSurfaces, 'anthropic-xml');
464
465
 
465
466
  return {
466
467
  messages: providerMessages,
@@ -30,6 +30,7 @@ import type {
30
30
  } from './types.js';
31
31
  import { normalizeToolPairs, mergeConsecutiveRoles } from './normalize-tool-pairs.js';
32
32
  import { isAcceptedImageMediaType, strippedImagePlaceholder } from '../utils/image-media.js';
33
+ import { assertCacheMarkersWithinLimit } from '../utils/cache-marker-budget.js';
33
34
 
34
35
  /** Index of the last content block that can carry cache_control. Anthropic
35
36
  * rejects cache_control on thinking / redacted_thinking blocks, so a cache
@@ -184,6 +185,7 @@ export class NativeFormatter implements PrefillFormatter {
184
185
  tools,
185
186
  systemPrompt,
186
187
  promptCaching = false,
188
+ cacheMarkers = 'membrane-system',
187
189
  cacheTtl,
188
190
  hasCacheMarker,
189
191
  contextPrefix,
@@ -209,7 +211,7 @@ export class NativeFormatter implements PrefillFormatter {
209
211
  // Add context prefix as first assistant message (for simulacrum seeding)
210
212
  if (contextPrefix) {
211
213
  const prefixBlock: Record<string, unknown> = { type: 'text', text: contextPrefix };
212
- if (promptCaching && cacheControl) {
214
+ if (promptCaching && cacheControl && cacheMarkers === 'membrane-system') {
213
215
  prefixBlock.cache_control = cacheControl;
214
216
  markedBreakpoints++;
215
217
  }
@@ -248,6 +250,13 @@ export class NativeFormatter implements PrefillFormatter {
248
250
  includeNames: participantMode === 'multiuser' && !isAssistant,
249
251
  });
250
252
 
253
+ if (
254
+ cacheMarkers === 'cm-owned' &&
255
+ content.some((block) => Boolean((block as Record<string, unknown>).cache_control))
256
+ ) {
257
+ throw new Error('cm-owned cache markers reject imported block-level cache_control');
258
+ }
259
+
251
260
  if (content.length === 0) {
252
261
  continue; // Skip empty messages
253
262
  }
@@ -302,7 +311,13 @@ export class NativeFormatter implements PrefillFormatter {
302
311
  // Build system content. Cache the system block only as a fallback — when no
303
312
  // message breakpoint was marked (see note above; otherwise a message
304
313
  // breakpoint already caches tools+system as part of its prefix).
305
- const cacheSystem = cacheControl && markedBreakpoints === 0 ? cacheControl : undefined;
314
+ if (markedBreakpoints > 4) {
315
+ throw new Error(`cache_control limit exceeded: ${markedBreakpoints} markers (maximum 4)`);
316
+ }
317
+ const cacheSystem =
318
+ cacheMarkers === 'membrane-system' && cacheControl && markedBreakpoints === 0
319
+ ? cacheControl
320
+ : undefined;
306
321
  let systemContent: unknown;
307
322
  if (typeof systemPrompt === 'string') {
308
323
  if (cacheSystem) {
@@ -327,6 +342,12 @@ export class NativeFormatter implements PrefillFormatter {
327
342
 
328
343
  // Native tools
329
344
  const nativeTools = tools?.length ? this.convertToNativeTools(tools) : undefined;
345
+ if (cacheMarkers === 'cm-owned') {
346
+ assertCacheMarkersWithinLimit(
347
+ { messages: mergedMessages, system: systemContent, tools: nativeTools },
348
+ 'native'
349
+ );
350
+ }
330
351
 
331
352
  return {
332
353
  messages: mergedMessages,
@@ -68,6 +68,9 @@ export interface BuildOptions {
68
68
  /** Enable prompt caching (Anthropic-specific) */
69
69
  promptCaching?: boolean;
70
70
 
71
+ /** See NormalizedRequest.cacheMarkers. */
72
+ cacheMarkers?: 'membrane-system' | 'cm-owned';
73
+
71
74
  /** Cache TTL for Anthropic prompt caching - '5m' (default) or '1h' for extended */
72
75
  cacheTtl?: '5m' | '1h';
73
76
 
package/src/index.ts CHANGED
@@ -37,3 +37,9 @@ export type {
37
37
  KeepaliveLane,
38
38
  KeepaliveSend,
39
39
  } from './cache-keepalive.js';
40
+
41
+ export {
42
+ computeCacheWireReceipt,
43
+ type CacheWireReceipt,
44
+ type CacheWireMarkerReceipt,
45
+ } from './cache-wire-receipt.js';
package/src/membrane.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  stripThinkingForPrefill,
36
36
  } from './utils/thinking-carriers.js';
37
37
  import {
38
+ assertCacheMarkersWithinLimit,
38
39
  countWireCacheMarkers,
39
40
  clampCacheMarkers,
40
41
  ownSystemBlocks,
@@ -51,6 +52,7 @@ import {
51
52
  unsupportedError,
52
53
  } from './types/index.js';
53
54
  import type { BuildResult } from './formatters/types.js';
55
+ import { computeCacheWireReceipt } from './cache-wire-receipt.js';
54
56
  import {
55
57
  parseToolCalls,
56
58
  formatToolResults,
@@ -247,7 +249,12 @@ export class Membrane {
247
249
 
248
250
  // Last exit before the adapter: the only place that sees EVERY
249
251
  // contribution (builder, formatter, passthrough, float, hook).
250
- clampCacheMarkers(finalRequest, 'complete');
252
+ if (request.cacheMarkers === 'cm-owned') {
253
+ assertCacheMarkersWithinLimit(finalRequest, 'complete');
254
+ } else {
255
+ clampCacheMarkers(finalRequest, 'complete');
256
+ }
257
+ request.onCacheWireReceipt?.(computeCacheWireReceipt(finalRequest));
251
258
 
252
259
  const rawProviderResponse = await this.adapter.complete(finalRequest, {
253
260
  signal: options.signal,
@@ -2153,6 +2160,7 @@ export class Membrane {
2153
2160
  thinking: request.config.thinking,
2154
2161
  systemPrompt: request.system,
2155
2162
  promptCaching: request.promptCaching ?? this.config.defaultPromptCaching ?? true, // Default true for backward compat
2163
+ cacheMarkers: request.cacheMarkers ?? 'membrane-system',
2156
2164
  cacheTtl: request.cacheTtl,
2157
2165
  additionalStopSequences,
2158
2166
  maxParticipantsForStop,
@@ -2266,8 +2274,11 @@ export class Membrane {
2266
2274
  // streaming path — stream(), streamYielding(), both tool loops — funnels
2267
2275
  // through here, so this is the one clamp they all get, and its tally is
2268
2276
  // therefore the only count that describes the wire.
2269
- const clampOutcome = clampCacheMarkers(finalRequest, 'streamOnce');
2270
- onWireCacheMarkers?.(clampOutcome.total);
2277
+ const markerCount = normalizedRequest.cacheMarkers === 'cm-owned'
2278
+ ? assertCacheMarkersWithinLimit(finalRequest, 'streamOnce')
2279
+ : clampCacheMarkers(finalRequest, 'streamOnce').total;
2280
+ normalizedRequest.onCacheWireReceipt?.(computeCacheWireReceipt(finalRequest));
2281
+ onWireCacheMarkers?.(markerCount);
2271
2282
 
2272
2283
  // Retries are only safe when the caller can discard the abandoned
2273
2284
  // attempt, so they require BOTH a budget and an onRetrying hook.
@@ -130,6 +130,18 @@ export function thinkingEnabled(request: ProviderRequest): boolean {
130
130
  // Adapter Configuration
131
131
  // ============================================================================
132
132
 
133
+ /**
134
+ * What the dynamicHeaders callback is told about the request it stamps.
135
+ * `lane` names the transport shape: 'stream' is the conversational turn loop,
136
+ * 'complete' the non-streamed lane (compression, side-calls, keepalive
137
+ * touches). A stamp that describes WHY the agent's turn fired belongs on the
138
+ * stream lane only — a compression call running in the background is not the
139
+ * turn, and stamping it with the turn's cause would lie to the ledger.
140
+ */
141
+ export interface DynamicHeadersContext {
142
+ lane: 'stream' | 'complete';
143
+ }
144
+
133
145
  export interface AnthropicAdapterConfig {
134
146
  /** API key (defaults to ANTHROPIC_API_KEY env var) */
135
147
  apiKey?: string | null;
@@ -157,7 +169,7 @@ export interface AnthropicAdapterConfig {
157
169
  * never replayed stale — an unstamped touch is honest, a stale stamp lies.
158
170
  * null/undefined/'' values are dropped.
159
171
  */
160
- dynamicHeaders?: () => Record<string, string | number | null | undefined>;
172
+ dynamicHeaders?: (ctx?: DynamicHeadersContext) => Record<string, string | number | null | undefined>;
161
173
 
162
174
  /** Default max tokens */
163
175
  defaultMaxTokens?: number;
@@ -196,7 +208,7 @@ export class AnthropicAdapter implements ProviderAdapter {
196
208
  /** Holds idle agents' cached prefixes warm; undefined when disabled. */
197
209
  readonly cacheKeepalive: CacheKeepalive | undefined;
198
210
  /** Live per-request header source (see AnthropicAdapterConfig.dynamicHeaders). */
199
- private readonly dynamicHeaders?: () => Record<string, string | number | null | undefined>;
211
+ private readonly dynamicHeaders?: (ctx?: DynamicHeadersContext) => Record<string, string | number | null | undefined>;
200
212
 
201
213
  constructor(config: AnthropicAdapterConfig = {}) {
202
214
  const clientOptions: ClientOptions = {
@@ -250,7 +262,7 @@ export class AnthropicAdapter implements ProviderAdapter {
250
262
  try {
251
263
  const response = await this.client.messages.create(fullRequest, {
252
264
  signal: options?.signal,
253
- headers: this.liveHeaders(headers),
265
+ headers: this.liveHeaders(headers, 'complete'),
254
266
  });
255
267
 
256
268
  return this.parseResponse(response, fullRequest);
@@ -323,7 +335,7 @@ export class AnthropicAdapter implements ProviderAdapter {
323
335
  try {
324
336
  const stream = await this.client.messages.stream(anthropicRequest, {
325
337
  signal: idleAbort.signal,
326
- headers: this.liveHeaders(this.betaHeaders(request)),
338
+ headers: this.liveHeaders(this.betaHeaders(request), 'stream'),
327
339
  });
328
340
 
329
341
  // Accumulate response metadata from SSE events directly, so we can
@@ -573,8 +585,8 @@ export class AnthropicAdapter implements ProviderAdapter {
573
585
  /** Base headers + the live dynamicHeaders stamp. Request time only: the
574
586
  * keepalive recorder receives the base headers BEFORE this merge, so
575
587
  * replayed touches never carry a stale telemetry value. */
576
- private liveHeaders(base: Record<string, string> | undefined): Record<string, string> | undefined {
577
- const dyn = this.dynamicHeaders?.();
588
+ private liveHeaders(base: Record<string, string> | undefined, lane: DynamicHeadersContext['lane']): Record<string, string> | undefined {
589
+ const dyn = this.dynamicHeaders?.({ lane });
578
590
  if (!dyn) return base;
579
591
  const out: Record<string, string> = { ...(base ?? {}) };
580
592
  for (const [k, v] of Object.entries(dyn)) {
@@ -7,6 +7,7 @@ export {
7
7
  toAnthropicContent,
8
8
  fromAnthropicContent,
9
9
  type AnthropicAdapterConfig,
10
+ type DynamicHeadersContext,
10
11
  } from './anthropic.js';
11
12
 
12
13
  export { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
@@ -112,12 +112,36 @@ export interface OpenAIAdapterConfig {
112
112
  // Model Detection Helpers
113
113
  // ============================================================================
114
114
 
115
+ /**
116
+ * Major version of a first-party GPT chat model id (`gpt-5` → 5,
117
+ * `gpt-5.4-mini` → 5, `gpt-6-astra` → 6, `gpt-4o` / `gpt-4.1` → 4). Leading
118
+ * digits only, no delimiter required, so a digit-plus-letter name (`gpt-4o`,
119
+ * a future `gpt-6o`) is classified by its generation too. Anything not
120
+ * `gpt-<digits>` (o-series, chatgpt-*, third-party ids) → undefined.
121
+ */
122
+ function gptGeneration(model: string): number | undefined {
123
+ const m = /^gpt-(\d+)/.exec(model);
124
+ return m ? Number(m[1]) : undefined;
125
+ }
126
+
127
+ /**
128
+ * GPT-5 and every later generation share the reasoning-model parameter
129
+ * surface (max_completion_tokens, default-only temperature/top_p, no stop).
130
+ * Matched by generation rather than by listing each release so a new one
131
+ * (gpt-6-astra, 2026-09) does not fall through to the legacy parameters and
132
+ * 400 at the wire: "Unsupported parameter: 'max_tokens' is not supported with
133
+ * this model. Use 'max_completion_tokens' instead."
134
+ */
135
+ function isReasoningGenerationGpt(model: string): boolean {
136
+ const gen = gptGeneration(model);
137
+ return gen !== undefined && gen >= 5;
138
+ }
139
+
115
140
  /**
116
141
  * Models that require max_completion_tokens instead of max_tokens
142
+ * (in addition to every GPT-5+ generation model, see isReasoningGenerationGpt)
117
143
  */
118
144
  const COMPLETION_TOKENS_MODELS = [
119
- 'gpt-5',
120
- 'gpt-5-mini',
121
145
  'o1',
122
146
  'o1-mini',
123
147
  'o1-preview',
@@ -130,15 +154,14 @@ const COMPLETION_TOKENS_MODELS = [
130
154
  * Check if a model requires max_completion_tokens parameter
131
155
  */
132
156
  function requiresCompletionTokens(model: string): boolean {
133
- return COMPLETION_TOKENS_MODELS.some(prefix => model.startsWith(prefix));
157
+ return isReasoningGenerationGpt(model) || COMPLETION_TOKENS_MODELS.some(prefix => model.startsWith(prefix));
134
158
  }
135
159
 
136
160
  /**
137
161
  * Models that don't support custom temperature (only default 1.0)
138
162
  */
139
163
  const NO_TEMPERATURE_MODELS = [
140
- 'gpt-5', // Base GPT-5 models
141
- 'gpt-5-mini',
164
+ // GPT-5+ generations are covered by isReasoningGenerationGpt
142
165
  'o1', // Reasoning models
143
166
  'o1-mini',
144
167
  'o1-preview',
@@ -151,7 +174,7 @@ const NO_TEMPERATURE_MODELS = [
151
174
  * Check if a model doesn't support custom temperature
152
175
  */
153
176
  function noTemperatureSupport(model: string): boolean {
154
- return NO_TEMPERATURE_MODELS.some(prefix => model.startsWith(prefix));
177
+ return isReasoningGenerationGpt(model) || NO_TEMPERATURE_MODELS.some(prefix => model.startsWith(prefix));
155
178
  }
156
179
 
157
180
  /**
@@ -164,12 +187,7 @@ function noTemperatureSupport(model: string): boolean {
164
187
  * included here as they use a different API path entirely.
165
188
  */
166
189
  const NO_STOP_MODELS = [
167
- // GPT-5.x chat models (all variants)
168
- 'gpt-5',
169
- 'gpt-5-mini',
170
- 'gpt-5-nano',
171
- 'gpt-5.1',
172
- 'gpt-5.2',
190
+ // GPT-5+ chat models (all variants) are covered by isReasoningGenerationGpt
173
191
  // Reasoning models that still don't support stop
174
192
  'o3', // o3 (full) doesn't support stop, but o3-mini does!
175
193
  'o4-mini',
@@ -179,7 +197,7 @@ const NO_STOP_MODELS = [
179
197
  * Check if a model doesn't support stop sequences
180
198
  */
181
199
  function noStopSupport(model: string): boolean {
182
- return NO_STOP_MODELS.some(prefix => model.startsWith(prefix));
200
+ return isReasoningGenerationGpt(model) || NO_STOP_MODELS.some(prefix => model.startsWith(prefix));
183
201
  }
184
202
 
185
203
  // ============================================================================
@@ -102,6 +102,10 @@ const PRICING_TABLE: Array<{ prefix: string; pricing: ModelPricing }> = [
102
102
  // INCLUDES that span (see UsageCacheConvention), which membrane normalizes
103
103
  // away before pricing, so cacheReadPerMillion is applied to a disjoint count.
104
104
  // --------------------------------------------------------------------------
105
+ {
106
+ prefix: 'gpt-6-astra',
107
+ pricing: { inputPerMillion: 10, outputPerMillion: 50, cacheReadPerMillion: 1.00, currency: 'USD', asOf: '2026-09-06' },
108
+ },
105
109
  {
106
110
  prefix: 'gpt-5.6-sol',
107
111
  pricing: { inputPerMillion: 4, outputPerMillion: 20, cacheReadPerMillion: 0.40, currency: 'USD', asOf: DEFAULT_PRICING_LAST_VERIFIED },
@@ -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
@@ -149,6 +149,35 @@ export function countWireCacheMarkers(surfaces: WireCacheSurfaces): number {
149
149
  return collectMarkedBlocks(surfaces).length;
150
150
  }
151
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
+
152
181
  /**
153
182
  * Bring a request inside the breakpoint budget, in place, at the last exit
154
183
  * before the adapter call. Two repairs, both loud: