@animalabs/membrane 0.5.69 → 0.5.70

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Formatter for stateless, provider-native OpenAI Responses histories.
3
+ *
4
+ * Imported/native items are carried either in message metadata under
5
+ * `openaiResponsesItems` or on content blocks as `rawItem`. Those items are
6
+ * emitted verbatim and in order. Normalized messages created after import are
7
+ * converted to Responses input items without rewriting the native prefix.
8
+ */
9
+
10
+ import type {
11
+ ContentBlock,
12
+ NormalizedMessage,
13
+ ToolCall,
14
+ ToolResult,
15
+ } from '../types/index.js';
16
+ import type {
17
+ BuildOptions,
18
+ BuildResult,
19
+ BlockEvent,
20
+ ParseResult,
21
+ PrefillFormatter,
22
+ StreamEmission,
23
+ StreamParser,
24
+ } from './types.js';
25
+
26
+ export const OPENAI_RESPONSES_ITEMS_METADATA_KEY = 'openaiResponsesItems';
27
+
28
+ class ResponsesPassthroughParser implements StreamParser {
29
+ private accumulated = '';
30
+ private blockIndex = 0;
31
+ private blockStarted = false;
32
+
33
+ push(chunk: string): void { this.accumulated += chunk; }
34
+ processChunk(chunk: string): ParseResult {
35
+ this.accumulated += chunk;
36
+ const meta = { type: 'text' as const, visible: true, blockIndex: this.blockIndex };
37
+ const emissions: StreamEmission[] = [];
38
+ const blockEvents: BlockEvent[] = [];
39
+ if (!this.blockStarted) {
40
+ const event: BlockEvent = { event: 'block_start', index: this.blockIndex, block: { type: 'text' } };
41
+ emissions.push({ kind: 'blockEvent', event });
42
+ blockEvents.push(event);
43
+ this.blockStarted = true;
44
+ }
45
+ emissions.push({ kind: 'content', text: chunk, meta });
46
+ return { emissions, content: [{ text: chunk, meta }], blockEvents };
47
+ }
48
+ flush(): ParseResult {
49
+ const emissions: StreamEmission[] = [];
50
+ const blockEvents: BlockEvent[] = [];
51
+ if (this.blockStarted) {
52
+ const event: BlockEvent = {
53
+ event: 'block_complete', index: this.blockIndex,
54
+ block: { type: 'text', content: this.accumulated },
55
+ };
56
+ emissions.push({ kind: 'blockEvent', event });
57
+ blockEvents.push(event);
58
+ this.blockStarted = false;
59
+ }
60
+ return { emissions, content: [], blockEvents };
61
+ }
62
+ getAccumulated(): string { return this.accumulated; }
63
+ reset(): void { this.accumulated = ''; this.blockIndex = 0; this.blockStarted = false; }
64
+ isInsideBlock(): boolean { return false; }
65
+ getCurrentBlockType() { return 'text' as const; }
66
+ getBlockIndex(): number { return this.blockIndex; }
67
+ incrementBlockIndex(): void { this.blockIndex++; }
68
+ getDepths() { return { functionCalls: 0, functionResults: 0, thinking: 0 }; }
69
+ resetForNewIteration(): void {}
70
+ }
71
+
72
+ type NativeItem = { type?: string; id?: string; [key: string]: unknown };
73
+
74
+ export class OpenAIResponsesFormatter implements PrefillFormatter {
75
+ readonly name = 'openai-responses';
76
+ readonly usesPrefill = false;
77
+
78
+ buildMessages(messages: NormalizedMessage[], options: BuildOptions): BuildResult {
79
+ const items: NativeItem[] = [];
80
+ let hasNativeHistory = false;
81
+
82
+ for (const message of messages) {
83
+ const nativeItems = message.metadata?.[OPENAI_RESPONSES_ITEMS_METADATA_KEY];
84
+ if (Array.isArray(nativeItems)) {
85
+ items.push(...nativeItems as NativeItem[]);
86
+ hasNativeHistory = true;
87
+ continue;
88
+ }
89
+
90
+ const seenRawItems = new Set<string>();
91
+ let pendingParts: ContentBlock[] = [];
92
+ const flushPending = () => {
93
+ if (pendingParts.length === 0) return;
94
+ items.push(...this.convertBlocks(message, pendingParts, options.assistantParticipant));
95
+ pendingParts = [];
96
+ };
97
+
98
+ for (const block of message.content) {
99
+ const rawItem = (block as unknown as { rawItem?: NativeItem }).rawItem;
100
+ if (!rawItem || typeof rawItem !== 'object') {
101
+ pendingParts.push(block);
102
+ continue;
103
+ }
104
+
105
+ flushPending();
106
+ hasNativeHistory = true;
107
+ const key = typeof rawItem.id === 'string'
108
+ ? `${rawItem.type ?? ''}:${rawItem.id}`
109
+ : JSON.stringify(rawItem);
110
+ if (!seenRawItems.has(key)) {
111
+ items.push(rawItem);
112
+ seenRawItems.add(key);
113
+ }
114
+ }
115
+ flushPending();
116
+ }
117
+
118
+ return {
119
+ // BuildResult's historical type says chat envelopes, but Membrane's
120
+ // provider boundary deliberately accepts unknown provider-native items.
121
+ messages: items as unknown as BuildResult['messages'],
122
+ // An imported rollout already contains its developer/system items.
123
+ // Re-injecting the recipe prompt would alter the stable prefix.
124
+ systemContent: hasNativeHistory ? undefined : options.systemPrompt,
125
+ stopSequences: options.additionalStopSequences ?? [],
126
+ nativeTools: options.tools?.map(tool => ({
127
+ type: 'function',
128
+ name: tool.name,
129
+ description: tool.description,
130
+ parameters: tool.inputSchema,
131
+ })),
132
+ ready: true,
133
+ };
134
+ }
135
+
136
+ private convertBlocks(
137
+ message: NormalizedMessage,
138
+ blocks: ContentBlock[],
139
+ assistantParticipant: string,
140
+ ): NativeItem[] {
141
+ const isAssistant = message.participant === assistantParticipant;
142
+ const out: NativeItem[] = [];
143
+ let messageParts: unknown[] = [];
144
+
145
+ const flushMessage = () => {
146
+ if (messageParts.length === 0) return;
147
+ out.push({
148
+ type: 'message',
149
+ role: isAssistant ? 'assistant' : 'user',
150
+ content: messageParts,
151
+ });
152
+ messageParts = [];
153
+ };
154
+
155
+ for (const block of blocks) {
156
+ if (block.type === 'text') {
157
+ messageParts.push({
158
+ type: isAssistant ? 'output_text' : 'input_text',
159
+ text: block.text,
160
+ });
161
+ } else if (block.type === 'image' && !isAssistant) {
162
+ const source = block.source;
163
+ messageParts.push(source.type === 'url'
164
+ ? { type: 'input_image', image_url: source.url }
165
+ : { type: 'input_image', image_url: `data:${source.mediaType};base64,${source.data}` });
166
+ } else if (block.type === 'tool_use') {
167
+ flushMessage();
168
+ out.push({
169
+ type: 'function_call',
170
+ call_id: block.id,
171
+ name: block.name,
172
+ arguments: JSON.stringify(block.input ?? {}),
173
+ });
174
+ } else if (block.type === 'tool_result') {
175
+ flushMessage();
176
+ out.push({
177
+ type: 'function_call_output',
178
+ call_id: block.toolUseId,
179
+ output: typeof block.content === 'string'
180
+ ? block.content
181
+ : JSON.stringify(block.content),
182
+ });
183
+ } else if (block.type === 'redacted_thinking') {
184
+ flushMessage();
185
+ out.push({ type: 'reasoning', encrypted_content: block.data });
186
+ }
187
+ }
188
+ flushMessage();
189
+ return out;
190
+ }
191
+
192
+ formatToolResults(results: ToolResult[]): string {
193
+ return JSON.stringify(results.map(result => ({
194
+ type: 'function_call_output',
195
+ call_id: result.toolUseId,
196
+ output: result.content,
197
+ })));
198
+ }
199
+
200
+ createStreamParser(): StreamParser { return new ResponsesPassthroughParser(); }
201
+ parseToolCalls(_content: string): ToolCall[] { return []; }
202
+ hasToolUse(_content: string): boolean { return false; }
203
+ parseContentBlocks(content: string): ContentBlock[] {
204
+ return content ? [{ type: 'text', text: content }] : [];
205
+ }
206
+ }
package/src/membrane.ts CHANGED
@@ -236,7 +236,7 @@ export class Membrane {
236
236
  // Auto mode: choose based on formatter
237
237
  // NativeFormatter → native tools via API
238
238
  // AnthropicXmlFormatter (default) → XML tools in prefill
239
- if (this.formatter.name === 'native') {
239
+ if (this.formatter.name === 'native' || this.formatter.name === 'openai-responses') {
240
240
  return 'native';
241
241
  }
242
242
 
@@ -986,6 +986,14 @@ export class Membrane {
986
986
  request: NormalizedRequest,
987
987
  messages: typeof request.messages
988
988
  ): any {
989
+ // Provider-native formatters own their complete input-item shape. The
990
+ // legacy implementation below is intentionally Anthropic-specific; using
991
+ // it for Responses would normalize away item IDs, encrypted reasoning,
992
+ // assistant phases, and compaction items.
993
+ if (this.formatter.name === 'openai-responses') {
994
+ return this.transformRequest({ ...request, messages }, this.formatter).providerRequest;
995
+ }
996
+
989
997
  // Convert messages to provider format
990
998
  const providerMessages: any[] = [];
991
999
 
@@ -1174,20 +1182,25 @@ export class Membrane {
1174
1182
  const blocks: ContentBlock[] = [];
1175
1183
  for (const item of content) {
1176
1184
  if (item.type === 'text') {
1177
- blocks.push({ type: 'text', text: item.text });
1185
+ blocks.push({
1186
+ type: 'text', text: item.text,
1187
+ ...(item.rawItem ? { rawItem: item.rawItem } : {}),
1188
+ } as ContentBlock);
1178
1189
  } else if (item.type === 'tool_use') {
1179
1190
  blocks.push({
1180
1191
  type: 'tool_use',
1181
1192
  id: item.id,
1182
1193
  name: unsanitizeToolName(item.name),
1183
1194
  input: item.input,
1184
- });
1195
+ ...(item.rawItem ? { rawItem: item.rawItem } : {}),
1196
+ } as ContentBlock);
1185
1197
  } else if (item.type === 'thinking') {
1186
1198
  blocks.push({
1187
1199
  type: 'thinking',
1188
1200
  thinking: item.thinking ?? '',
1189
1201
  ...(item.signature ? { signature: item.signature } : {}),
1190
- });
1202
+ ...(item.rawItem ? { rawItem: item.rawItem } : {}),
1203
+ } as ContentBlock);
1191
1204
  } else if (item.type === 'redacted_thinking') {
1192
1205
  // Pass through verbatim — carries the encrypted `data` payload
1193
1206
  blocks.push({ ...item } as ContentBlock);
@@ -1197,6 +1210,12 @@ export class Membrane {
1197
1210
  data: item.data,
1198
1211
  mimeType: item.mimeType,
1199
1212
  });
1213
+ } else if (item.rawItem) {
1214
+ // Opaque Responses items such as encrypted compaction or custom
1215
+ // tool records have no normalized ContentBlock equivalent. Retain a
1216
+ // zero-width carrier so Chronicle and the Responses formatter can
1217
+ // replay the raw item without surfacing synthetic prompt text.
1218
+ blocks.push({ type: 'text', text: '', rawItem: item.rawItem } as ContentBlock);
1200
1219
  }
1201
1220
  }
1202
1221
  return blocks;
@@ -186,6 +186,11 @@ export class AnthropicAdapter implements ProviderAdapter {
186
186
  let outputTokens = 0;
187
187
  let cacheCreationTokens: number | undefined;
188
188
  let cacheReadTokens: number | undefined;
189
+ let cacheCreation5mTokens: number | undefined;
190
+ let cacheCreation1hTokens: number | undefined;
191
+ let hasCacheCreationBreakdown = false;
192
+ let inferenceGeo: string | undefined;
193
+ let serviceTier: string | undefined;
189
194
  let stopReason: string = 'end_turn';
190
195
  let stopSequence: string | undefined;
191
196
  let stopDetails: unknown;
@@ -207,10 +212,22 @@ export class AnthropicAdapter implements ProviderAdapter {
207
212
  resetIdleTimer();
208
213
  if (event.type === 'message_start') {
209
214
  model = event.message.model;
210
- const usage = event.message.usage as unknown as Record<string, number>;
211
- inputTokens = usage.input_tokens ?? 0;
212
- cacheCreationTokens = usage.cache_creation_input_tokens;
213
- cacheReadTokens = usage.cache_read_input_tokens;
215
+ const usage = event.message.usage as unknown as Record<string, unknown>;
216
+ inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
217
+ cacheCreationTokens = typeof usage.cache_creation_input_tokens === 'number'
218
+ ? usage.cache_creation_input_tokens : undefined;
219
+ cacheReadTokens = typeof usage.cache_read_input_tokens === 'number'
220
+ ? usage.cache_read_input_tokens : undefined;
221
+ const cacheCreation = usage.cache_creation as Record<string, unknown> | undefined;
222
+ if (cacheCreation) {
223
+ hasCacheCreationBreakdown = true;
224
+ cacheCreation5mTokens = typeof cacheCreation.ephemeral_5m_input_tokens === 'number'
225
+ ? cacheCreation.ephemeral_5m_input_tokens : 0;
226
+ cacheCreation1hTokens = typeof cacheCreation.ephemeral_1h_input_tokens === 'number'
227
+ ? cacheCreation.ephemeral_1h_input_tokens : 0;
228
+ }
229
+ inferenceGeo = typeof usage.inference_geo === 'string' ? usage.inference_geo : undefined;
230
+ serviceTier = typeof usage.service_tier === 'string' ? usage.service_tier : undefined;
214
231
 
215
232
  } else if (event.type === 'content_block_start') {
216
233
  currentBlockIndex = event.index;
@@ -324,6 +341,14 @@ export class AnthropicAdapter implements ProviderAdapter {
324
341
  output_tokens: outputTokens,
325
342
  cache_creation_input_tokens: cacheCreationTokens,
326
343
  cache_read_input_tokens: cacheReadTokens,
344
+ ...(hasCacheCreationBreakdown ? {
345
+ cache_creation: {
346
+ ephemeral_5m_input_tokens: cacheCreation5mTokens ?? 0,
347
+ ephemeral_1h_input_tokens: cacheCreation1hTokens ?? 0,
348
+ },
349
+ } : {}),
350
+ ...(inferenceGeo ? { inference_geo: inferenceGeo } : {}),
351
+ ...(serviceTier ? { service_tier: serviceTier } : {}),
327
352
  },
328
353
  },
329
354
  };
@@ -60,3 +60,14 @@ export {
60
60
  OpenAIResponsesAdapter,
61
61
  type OpenAIResponsesAdapterConfig,
62
62
  } from './openai-responses.js';
63
+
64
+ export {
65
+ OpenAIResponsesAPIAdapter,
66
+ type OpenAIResponsesAPIAdapterConfig,
67
+ type OpenAIResponsesAPIContentBlock,
68
+ type OpenAIResponsesAPIProviderResponse,
69
+ type OpenAIResponsesAPIRequest,
70
+ type OpenAIResponsesAPIResponse,
71
+ type OpenAIResponsesInputItem,
72
+ type OpenAIResponsesOutputItem,
73
+ } from './openai-responses-api.js';