@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.
- package/dist/cache-wire-receipt.d.ts +13 -0
- package/dist/cache-wire-receipt.d.ts.map +1 -0
- package/dist/cache-wire-receipt.js +108 -0
- package/dist/cache-wire-receipt.js.map +1 -0
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +9 -6
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +16 -3
- package/dist/formatters/native.js.map +1 -1
- package/dist/formatters/types.d.ts +2 -0
- package/dist/formatters/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +15 -4
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +12 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +4 -4
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +29 -13
- package/dist/providers/openai.js.map +1 -1
- package/dist/registry/default-pricing.d.ts.map +1 -1
- package/dist/registry/default-pricing.js +4 -0
- package/dist/registry/default-pricing.js.map +1 -1
- package/dist/types/request.d.ts +6 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.d.ts +9 -0
- package/dist/utils/cache-marker-budget.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.js +19 -0
- package/dist/utils/cache-marker-budget.js.map +1 -1
- package/package.json +1 -1
- package/src/cache-wire-receipt.ts +125 -0
- package/src/formatters/anthropic-xml.ts +9 -8
- package/src/formatters/native.ts +23 -2
- package/src/formatters/types.ts +3 -0
- package/src/index.ts +6 -0
- package/src/membrane.ts +14 -3
- package/src/providers/anthropic.ts +18 -6
- package/src/providers/index.ts +1 -0
- package/src/providers/openai.ts +31 -13
- package/src/registry/default-pricing.ts +4 -0
- package/src/types/request.ts +7 -0
- 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
|
|
461
|
-
|
|
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,
|
package/src/formatters/native.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/formatters/types.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|
|
2270
|
-
|
|
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)) {
|
package/src/providers/index.ts
CHANGED
package/src/providers/openai.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 },
|
package/src/types/request.ts
CHANGED
|
@@ -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:
|