@animalabs/membrane 0.5.67 → 0.5.69
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/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +14 -8
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/native.d.ts +3 -0
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +70 -14
- package/dist/formatters/native.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +56 -17
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic-tool-schema.d.ts +43 -0
- package/dist/providers/anthropic-tool-schema.d.ts.map +1 -0
- package/dist/providers/anthropic-tool-schema.js +165 -0
- package/dist/providers/anthropic-tool-schema.js.map +1 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +102 -4
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +15 -0
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openrouter.d.ts +6 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +87 -4
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/types/provider.d.ts +8 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +8 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/utils/image-media.d.ts +54 -0
- package/dist/utils/image-media.d.ts.map +1 -0
- package/dist/utils/image-media.js +135 -0
- package/dist/utils/image-media.js.map +1 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +15 -9
- package/dist/utils/tool-parser.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/anthropic-xml.ts +13 -8
- package/src/formatters/native.ts +70 -14
- package/src/membrane.ts +60 -17
- package/src/providers/anthropic-tool-schema.ts +194 -0
- package/src/providers/anthropic.ts +111 -6
- package/src/providers/gemini.ts +14 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/openrouter.ts +94 -5
- package/src/types/provider.ts +7 -1
- package/src/types/request.ts +8 -0
- package/src/utils/image-media.ts +150 -0
- package/src/utils/tool-parser.ts +14 -9
package/src/formatters/native.ts
CHANGED
|
@@ -29,6 +29,19 @@ import type {
|
|
|
29
29
|
StreamEmission,
|
|
30
30
|
} from './types.js';
|
|
31
31
|
import { normalizeToolPairs, mergeConsecutiveRoles } from './normalize-tool-pairs.js';
|
|
32
|
+
import { isAcceptedImageMediaType, strippedImagePlaceholder } from '../utils/image-media.js';
|
|
33
|
+
|
|
34
|
+
/** Index of the last content block that can carry cache_control. Anthropic
|
|
35
|
+
* rejects cache_control on thinking / redacted_thinking blocks, so a cache
|
|
36
|
+
* breakpoint must attach to the last NON-thinking block. Returns -1 when the
|
|
37
|
+
* message has only thinking blocks (the breakpoint is then skipped). */
|
|
38
|
+
function lastCacheableBlockIndex(blocks: Array<Record<string, unknown>>): number {
|
|
39
|
+
for (let k = blocks.length - 1; k >= 0; k--) {
|
|
40
|
+
const t = blocks[k]?.type as string | undefined;
|
|
41
|
+
if (t !== 'thinking' && t !== 'redacted_thinking') return k;
|
|
42
|
+
}
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
// ============================================================================
|
|
34
47
|
// Configuration
|
|
@@ -243,8 +256,9 @@ export class NativeFormatter implements PrefillFormatter {
|
|
|
243
256
|
if (hasCacheMarker && hasCacheMarker(message, i) && cacheControl && providerMessages.length > 0) {
|
|
244
257
|
const prevMsg = providerMessages[providerMessages.length - 1]!;
|
|
245
258
|
const prevContent = Array.isArray(prevMsg.content) ? prevMsg.content as Record<string, unknown>[] : [];
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
const prevIdx = lastCacheableBlockIndex(prevContent);
|
|
260
|
+
if (prevIdx >= 0) {
|
|
261
|
+
prevContent[prevIdx]!.cache_control = cacheControl;
|
|
248
262
|
markedBreakpoints++;
|
|
249
263
|
}
|
|
250
264
|
}
|
|
@@ -253,8 +267,11 @@ export class NativeFormatter implements PrefillFormatter {
|
|
|
253
267
|
|
|
254
268
|
// cacheBreakpoint: cache up to and INCLUDING this message — tag last block
|
|
255
269
|
if (message.cacheBreakpoint && cacheControl && content.length > 0) {
|
|
256
|
-
(content
|
|
257
|
-
|
|
270
|
+
const bpIdx = lastCacheableBlockIndex(content as Record<string, unknown>[]);
|
|
271
|
+
if (bpIdx >= 0) {
|
|
272
|
+
(content[bpIdx] as Record<string, unknown>).cache_control = cacheControl;
|
|
273
|
+
markedBreakpoints++;
|
|
274
|
+
}
|
|
258
275
|
}
|
|
259
276
|
}
|
|
260
277
|
|
|
@@ -349,6 +366,25 @@ export class NativeFormatter implements PrefillFormatter {
|
|
|
349
366
|
// PRIVATE HELPERS
|
|
350
367
|
// ==========================================================================
|
|
351
368
|
|
|
369
|
+
/** Replace API-unacceptable image blocks nested in tool_result content with
|
|
370
|
+
* text placeholders. Non-array content passes through untouched. */
|
|
371
|
+
private static sanitizeToolResultContent(content: unknown): unknown {
|
|
372
|
+
if (!Array.isArray(content)) return content;
|
|
373
|
+
return content.map((item) => {
|
|
374
|
+
if (
|
|
375
|
+
item &&
|
|
376
|
+
typeof item === 'object' &&
|
|
377
|
+
(item as { type?: string }).type === 'image'
|
|
378
|
+
) {
|
|
379
|
+
const src = (item as { source?: { media_type?: string } }).source;
|
|
380
|
+
if (!isAcceptedImageMediaType(src?.media_type)) {
|
|
381
|
+
return strippedImagePlaceholder(src?.media_type);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return item;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
352
388
|
private convertContent(
|
|
353
389
|
content: ContentBlock[],
|
|
354
390
|
participant: string,
|
|
@@ -371,19 +407,39 @@ export class NativeFormatter implements PrefillFormatter {
|
|
|
371
407
|
result.push(textBlock);
|
|
372
408
|
} else if (block.type === 'image') {
|
|
373
409
|
if (block.source.type === 'base64') {
|
|
374
|
-
|
|
375
|
-
type:
|
|
410
|
+
if (!isAcceptedImageMediaType(block.source.mediaType)) {
|
|
411
|
+
// Unacceptable media type (e.g. image/svg): degrade to a text
|
|
412
|
+
// placeholder instead of poisoning the whole request.
|
|
413
|
+
result.push(strippedImagePlaceholder(block.source.mediaType));
|
|
414
|
+
} else {
|
|
415
|
+
const imageBlock: Record<string, unknown> = {
|
|
416
|
+
type: 'image',
|
|
417
|
+
source: {
|
|
418
|
+
type: 'base64',
|
|
419
|
+
media_type: block.source.mediaType,
|
|
420
|
+
data: block.source.data,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
// Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
|
|
424
|
+
if (block.sourceUrl) {
|
|
425
|
+
imageBlock.sourceUrl = block.sourceUrl;
|
|
426
|
+
}
|
|
427
|
+
result.push(imageBlock);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else if (block.type === 'audio') {
|
|
431
|
+
// Pass audio through in the same shape as images — the provider
|
|
432
|
+
// adapters convert it (Gemini → inlineData, OpenRouter → input_audio).
|
|
433
|
+
// Whether a given model accepts audio is the provider/model's concern.
|
|
434
|
+
if (block.source.type === 'base64') {
|
|
435
|
+
result.push({
|
|
436
|
+
type: 'audio',
|
|
376
437
|
source: {
|
|
377
438
|
type: 'base64',
|
|
378
439
|
media_type: block.source.mediaType,
|
|
379
440
|
data: block.source.data,
|
|
380
441
|
},
|
|
381
|
-
};
|
|
382
|
-
// Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
|
|
383
|
-
if (block.sourceUrl) {
|
|
384
|
-
imageBlock.sourceUrl = block.sourceUrl;
|
|
385
|
-
}
|
|
386
|
-
result.push(imageBlock);
|
|
442
|
+
});
|
|
387
443
|
}
|
|
388
444
|
} else if (block.type === 'tool_use') {
|
|
389
445
|
result.push({
|
|
@@ -396,7 +452,7 @@ export class NativeFormatter implements PrefillFormatter {
|
|
|
396
452
|
result.push({
|
|
397
453
|
type: 'tool_result',
|
|
398
454
|
tool_use_id: block.toolUseId,
|
|
399
|
-
content: block.content,
|
|
455
|
+
content: NativeFormatter.sanitizeToolResultContent(block.content),
|
|
400
456
|
is_error: block.isError,
|
|
401
457
|
});
|
|
402
458
|
} else if (block.type === 'thinking') {
|
|
@@ -414,7 +470,7 @@ export class NativeFormatter implements PrefillFormatter {
|
|
|
414
470
|
} else if (block.type === 'redacted_thinking') {
|
|
415
471
|
// Pass through verbatim (carries encrypted data field)
|
|
416
472
|
result.push({ ...(block as unknown as Record<string, unknown>) });
|
|
417
|
-
} else if (block.type === 'document'
|
|
473
|
+
} else if (block.type === 'document') {
|
|
418
474
|
hasUnsupportedMedia = true;
|
|
419
475
|
}
|
|
420
476
|
}
|
package/src/membrane.ts
CHANGED
|
@@ -55,6 +55,11 @@ import { AnthropicXmlFormatter } from './formatters/anthropic-xml.js';
|
|
|
55
55
|
import { normalizeToolPairs, mergeConsecutiveRoles } from './formatters/normalize-tool-pairs.js';
|
|
56
56
|
import { YieldingStreamImpl } from './yielding-stream.js';
|
|
57
57
|
import { calculateCost } from './utils/cost.js';
|
|
58
|
+
import {
|
|
59
|
+
isAcceptedImageMediaType,
|
|
60
|
+
strippedImagePlaceholder,
|
|
61
|
+
shedImagesToFitByteBudget, assertWithinByteBudget,
|
|
62
|
+
} from './utils/image-media.js';
|
|
58
63
|
import { getDefaultPricing } from './registry/default-pricing.js';
|
|
59
64
|
|
|
60
65
|
// ============================================================================
|
|
@@ -990,6 +995,14 @@ export class Membrane {
|
|
|
990
995
|
const promptCaching = request.promptCaching ?? true;
|
|
991
996
|
const cacheControl = promptCaching ? { type: 'ephemeral' as const, ...(request.cacheTtl ? { ttl: request.cacheTtl } : {}) } : undefined;
|
|
992
997
|
|
|
998
|
+
// Anthropic allows at most 4 cache_control breakpoints per request. The
|
|
999
|
+
// message breakpoints are the valuable ones (they cache the longest prefixes,
|
|
1000
|
+
// and every one already includes tools+system at the front of the request).
|
|
1001
|
+
// So tools/system get a breakpoint only as a FALLBACK — when no message
|
|
1002
|
+
// breakpoint was marked — otherwise they're redundant and would push the
|
|
1003
|
+
// total past 4, which the API hard-rejects (the agent goes unresponsive).
|
|
1004
|
+
let messageBreakpoints = 0;
|
|
1005
|
+
|
|
993
1006
|
for (const msg of messages) {
|
|
994
1007
|
const isAssistant = msg.participant === assistantName;
|
|
995
1008
|
const role = isAssistant ? 'assistant' : 'user';
|
|
@@ -1037,19 +1050,26 @@ export class Membrane {
|
|
|
1037
1050
|
content.push({ ...(block as unknown as Record<string, unknown>) });
|
|
1038
1051
|
} else if (block.type === 'image') {
|
|
1039
1052
|
if (block.source.type === 'base64') {
|
|
1040
|
-
|
|
1041
|
-
type:
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1053
|
+
if (!isAcceptedImageMediaType(block.source.mediaType)) {
|
|
1054
|
+
// API-unacceptable media type (e.g. image/svg): degrade to a
|
|
1055
|
+
// loud text placeholder instead of poisoning the whole request
|
|
1056
|
+
// (one bad stored block otherwise 400s every compile forever).
|
|
1057
|
+
content.push(strippedImagePlaceholder(block.source.mediaType));
|
|
1058
|
+
} else {
|
|
1059
|
+
const imageBlock: Record<string, unknown> = {
|
|
1060
|
+
type: 'image',
|
|
1061
|
+
source: {
|
|
1062
|
+
type: 'base64',
|
|
1063
|
+
media_type: block.source.mediaType,
|
|
1064
|
+
data: block.source.data,
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
// Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
|
|
1068
|
+
if (block.sourceUrl) {
|
|
1069
|
+
imageBlock.sourceUrl = block.sourceUrl;
|
|
1070
|
+
}
|
|
1071
|
+
content.push(imageBlock);
|
|
1051
1072
|
}
|
|
1052
|
-
content.push(imageBlock);
|
|
1053
1073
|
}
|
|
1054
1074
|
}
|
|
1055
1075
|
}
|
|
@@ -1057,6 +1077,7 @@ export class Membrane {
|
|
|
1057
1077
|
// Apply cache_control to last block of messages with cacheBreakpoint
|
|
1058
1078
|
if (msg.cacheBreakpoint && cacheControl && content.length > 0) {
|
|
1059
1079
|
content[content.length - 1].cache_control = cacheControl;
|
|
1080
|
+
messageBreakpoints++;
|
|
1060
1081
|
}
|
|
1061
1082
|
|
|
1062
1083
|
providerMessages.push({ role, content });
|
|
@@ -1093,18 +1114,21 @@ export class Membrane {
|
|
|
1093
1114
|
description: tool.description,
|
|
1094
1115
|
input_schema: tool.inputSchema,
|
|
1095
1116
|
};
|
|
1096
|
-
// Cache the tool list
|
|
1097
|
-
|
|
1117
|
+
// Cache the tool list (last tool) only as a fallback — a marked message
|
|
1118
|
+
// breakpoint already caches the tools as part of its prefix.
|
|
1119
|
+
if (cacheControl && messageBreakpoints === 0 && request.tools && idx === request.tools.length - 1) {
|
|
1098
1120
|
t.cache_control = cacheControl;
|
|
1099
1121
|
}
|
|
1100
1122
|
return t;
|
|
1101
1123
|
});
|
|
1102
1124
|
|
|
1103
|
-
// Wrap system prompt with cache_control
|
|
1125
|
+
// Wrap system prompt with cache_control only as a fallback (no message
|
|
1126
|
+
// breakpoint marked); otherwise a message breakpoint already caches
|
|
1127
|
+
// tools+system as part of its prefix.
|
|
1104
1128
|
let system: unknown = request.system;
|
|
1105
|
-
if (cacheControl && typeof system === 'string' && system.length > 0) {
|
|
1129
|
+
if (cacheControl && messageBreakpoints === 0 && typeof system === 'string' && system.length > 0) {
|
|
1106
1130
|
system = [{ type: 'text', text: system, cache_control: cacheControl }];
|
|
1107
|
-
} else if (cacheControl && Array.isArray(system) && system.length > 0) {
|
|
1131
|
+
} else if (cacheControl && messageBreakpoints === 0 && Array.isArray(system) && system.length > 0) {
|
|
1108
1132
|
const blocks = system as Record<string, unknown>[];
|
|
1109
1133
|
system = blocks.map((block, idx) =>
|
|
1110
1134
|
idx === blocks.length - 1 ? { ...block, cache_control: cacheControl } : block
|
|
@@ -1120,6 +1144,14 @@ export class Membrane {
|
|
|
1120
1144
|
// Anthropic requires temperature=1 when extended thinking is enabled
|
|
1121
1145
|
const temperature = alwaysOnThinking ? undefined : (thinking ? 1 : request.config.temperature);
|
|
1122
1146
|
|
|
1147
|
+
// Byte-wall policy point (see transformRequest): loud failure unless the
|
|
1148
|
+
// caller explicitly owns image loss.
|
|
1149
|
+
if (request.shedOversizeImages) {
|
|
1150
|
+
shedImagesToFitByteBudget(mergedMessages, undefined, 'buildNativeToolRequest');
|
|
1151
|
+
} else {
|
|
1152
|
+
assertWithinByteBudget(mergedMessages, undefined, 'buildNativeToolRequest');
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1123
1155
|
return {
|
|
1124
1156
|
model: request.config.model,
|
|
1125
1157
|
maxTokens: request.config.maxTokens,
|
|
@@ -1357,6 +1389,17 @@ export class Membrane {
|
|
|
1357
1389
|
prefillUserMessage: request.prefillUserMessage,
|
|
1358
1390
|
});
|
|
1359
1391
|
|
|
1392
|
+
// Byte-wall policy point (2026-07-12): transformRequest serves BOTH
|
|
1393
|
+
// complete() and the streaming path through EVERY adapter. Oversize
|
|
1394
|
+
// requests FAIL LOUDLY here, before the API round-trip, unless the
|
|
1395
|
+
// caller explicitly owns image loss via `shedOversizeImages` (and the
|
|
1396
|
+
// shed itself reports at error grade). No silent transport mutation.
|
|
1397
|
+
if (request.shedOversizeImages) {
|
|
1398
|
+
shedImagesToFitByteBudget(buildResult.messages, undefined, 'transformRequest');
|
|
1399
|
+
} else {
|
|
1400
|
+
assertWithinByteBudget(buildResult.messages, undefined, 'transformRequest');
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1360
1403
|
const providerRequest = {
|
|
1361
1404
|
...this.getBaseProviderParams(request.config),
|
|
1362
1405
|
messages: buildResult.messages,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic tool-schema normalization
|
|
3
|
+
*
|
|
4
|
+
* MCP permits a tool's input JSON Schema to have a root-level `oneOf` /
|
|
5
|
+
* `anyOf` / `allOf` (a union of alternative argument shapes). The Anthropic
|
|
6
|
+
* API does not: `input_schema` must be a single object-type schema, and a
|
|
7
|
+
* root-level combinator is rejected with 400
|
|
8
|
+
* ("input_schema does not support oneOf, allOf, or anyOf at the top level").
|
|
9
|
+
* One such tool 400s the entire inference — same philosophy as
|
|
10
|
+
* `normalize-tool-pairs`: repair at the wire boundary instead of letting a
|
|
11
|
+
* producer-side quirk kill the turn.
|
|
12
|
+
*
|
|
13
|
+
* `flattenRootSchemaUnion` rewrites the common case — every union variant is
|
|
14
|
+
* itself an object schema — into a single object schema:
|
|
15
|
+
*
|
|
16
|
+
* - `properties` is the merge of all variants' properties (first wins on
|
|
17
|
+
* key collision).
|
|
18
|
+
* - `required`: for `allOf` (intersective — every variant applies) the
|
|
19
|
+
* union of the variants' required lists; for `oneOf`/`anyOf`
|
|
20
|
+
* (alternatives) only keys required by *every* variant stay required.
|
|
21
|
+
* - A short note enumerating the alternative argument groups is appended
|
|
22
|
+
* to the description so the model still sees the union intent.
|
|
23
|
+
* - Variant-level `additionalProperties: false` is dropped: the merged
|
|
24
|
+
* object is a permissive superset of the alternatives, and `false`
|
|
25
|
+
* could reject payloads valid under one of the original variants.
|
|
26
|
+
*
|
|
27
|
+
* If any variant is not object-shaped (e.g. a root `oneOf` of a string and
|
|
28
|
+
* an object), the union cannot be merged into properties; we fall back to a
|
|
29
|
+
* permissive object schema carrying the serialized union in the description.
|
|
30
|
+
* Degenerate, but it does not 400 — the tool stays callable and the model
|
|
31
|
+
* sees the accepted shapes.
|
|
32
|
+
*
|
|
33
|
+
* Nested combinators (inside `properties`, array `items`, etc.) are legal
|
|
34
|
+
* for Anthropic and are left untouched; only the root is rewritten.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isMergeableObjectVariant(variant: Record<string, unknown>): boolean {
|
|
42
|
+
return (
|
|
43
|
+
variant.type === 'object' ||
|
|
44
|
+
(variant.type === undefined && isPlainObject(variant.properties))
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stringRequired(value: unknown): string[] {
|
|
49
|
+
return Array.isArray(value)
|
|
50
|
+
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
51
|
+
: [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ROOT_UNION_KEYS = ['oneOf', 'anyOf', 'allOf'] as const;
|
|
55
|
+
|
|
56
|
+
/** Max length of the description we synthesize on the fallback path. */
|
|
57
|
+
const MAX_FALLBACK_DESCRIPTION = 4000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rewrite a root-level `oneOf`/`anyOf`/`allOf` in a tool input schema into a
|
|
61
|
+
* single Anthropic-acceptable object schema. Returns the input unchanged
|
|
62
|
+
* (same reference) when there is nothing to repair, so callers can cheaply
|
|
63
|
+
* detect whether a rewrite happened.
|
|
64
|
+
*/
|
|
65
|
+
export function flattenRootSchemaUnion(schema: unknown): unknown {
|
|
66
|
+
if (!isPlainObject(schema)) return schema;
|
|
67
|
+
|
|
68
|
+
// A root may carry more than one combinator at once (e.g. `{ oneOf, anyOf }`).
|
|
69
|
+
// These are sibling keywords: a valid instance must satisfy every one of
|
|
70
|
+
// them (an implicit `allOf` across the combinators). Process *all* present
|
|
71
|
+
// keys — handling only the first would strip the others' variants (the
|
|
72
|
+
// destructuring below removes all three keys), silently losing their
|
|
73
|
+
// properties and required lists.
|
|
74
|
+
const presentKeys = ROOT_UNION_KEYS.filter(
|
|
75
|
+
(key) => Array.isArray(schema[key]) && (schema[key] as unknown[]).length > 0,
|
|
76
|
+
);
|
|
77
|
+
if (presentKeys.length === 0) return schema;
|
|
78
|
+
|
|
79
|
+
const combinators = presentKeys.map((key) => {
|
|
80
|
+
const raw = schema[key] as unknown[];
|
|
81
|
+
return { key, raw, variants: raw.filter(isPlainObject) };
|
|
82
|
+
});
|
|
83
|
+
const rawVariantsAll: unknown[] = combinators.flatMap(({ raw }) => raw);
|
|
84
|
+
|
|
85
|
+
// Keep every root key except the combinators themselves.
|
|
86
|
+
const { oneOf: _oneOf, anyOf: _anyOf, allOf: _allOf, ...rest } = schema;
|
|
87
|
+
|
|
88
|
+
const allMergeable = combinators.every(
|
|
89
|
+
({ raw, variants }) =>
|
|
90
|
+
variants.length === raw.length && variants.every(isMergeableObjectVariant),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (allMergeable) {
|
|
94
|
+
// Common case: every variant of every present combinator is an object
|
|
95
|
+
// schema — merge them all.
|
|
96
|
+
const properties: Record<string, unknown> = isPlainObject(rest.properties)
|
|
97
|
+
? { ...rest.properties }
|
|
98
|
+
: {};
|
|
99
|
+
for (const { variants } of combinators) {
|
|
100
|
+
for (const variant of variants) {
|
|
101
|
+
if (isPlainObject(variant.properties)) {
|
|
102
|
+
for (const [key, propSchema] of Object.entries(variant.properties)) {
|
|
103
|
+
if (!(key in properties)) properties[key] = propSchema;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Per-combinator required semantics: `allOf` unions its variants' required
|
|
110
|
+
// lists (every variant applies); `oneOf`/`anyOf` keep only keys required by
|
|
111
|
+
// every variant (alternatives). Across combinators they all apply at once,
|
|
112
|
+
// so the effective required set is the union of the per-combinator results.
|
|
113
|
+
const mergedRequired = combinators.flatMap(({ key, variants }) => {
|
|
114
|
+
const variantRequired = variants.map((variant) => stringRequired(variant.required));
|
|
115
|
+
return key === 'allOf'
|
|
116
|
+
? [...new Set(variantRequired.flat())]
|
|
117
|
+
: variantRequired.reduce(
|
|
118
|
+
(acc, req) => acc.filter((k) => req.includes(k)),
|
|
119
|
+
variantRequired[0] ?? [],
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
const required = [
|
|
123
|
+
...new Set([...stringRequired(rest.required), ...mergedRequired]),
|
|
124
|
+
].filter((key) => key in properties);
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
properties: _properties,
|
|
128
|
+
required: _required,
|
|
129
|
+
additionalProperties: _additionalProperties,
|
|
130
|
+
...restSansObjectKeys
|
|
131
|
+
} = rest;
|
|
132
|
+
|
|
133
|
+
const result: Record<string, unknown> = {
|
|
134
|
+
...restSansObjectKeys,
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties,
|
|
137
|
+
};
|
|
138
|
+
if (required.length > 0) result.required = required;
|
|
139
|
+
|
|
140
|
+
// For alternatives, preserve the union intent in prose so the model
|
|
141
|
+
// still knows the arguments come in groups (one line per combinator).
|
|
142
|
+
const noteParts = combinators
|
|
143
|
+
.filter(({ key, variants }) => key !== 'allOf' && variants.length > 1)
|
|
144
|
+
.map(({ variants }) => {
|
|
145
|
+
const groups = variants
|
|
146
|
+
.map((variant) => {
|
|
147
|
+
const req = stringRequired(variant.required);
|
|
148
|
+
return req.length > 0 ? `(${req.join(', ')})` : '(no required fields)';
|
|
149
|
+
})
|
|
150
|
+
.join(' | ');
|
|
151
|
+
return `Provide one of the following argument groups: ${groups}.`;
|
|
152
|
+
});
|
|
153
|
+
if (noteParts.length > 0) {
|
|
154
|
+
const note = noteParts.join('\n');
|
|
155
|
+
result.description =
|
|
156
|
+
typeof result.description === 'string' && result.description.length > 0
|
|
157
|
+
? `${result.description}\n${note}`
|
|
158
|
+
: note;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fallback: at least one variant (across the present combinators) is not an
|
|
165
|
+
// object schema (or not a schema at all). Emit a permissive object schema and
|
|
166
|
+
// carry the union(s) into the description so the intent survives.
|
|
167
|
+
const label = presentKeys.join('/');
|
|
168
|
+
let note: string;
|
|
169
|
+
try {
|
|
170
|
+
note = `Accepts one of the following input shapes (flattened from a root-level ${label}): ${JSON.stringify(rawVariantsAll)}`;
|
|
171
|
+
} catch {
|
|
172
|
+
note = `Accepts one of ${rawVariantsAll.length} alternative input shapes (root-level ${label} flattened).`;
|
|
173
|
+
}
|
|
174
|
+
const baseDescription =
|
|
175
|
+
typeof rest.description === 'string' && rest.description.length > 0
|
|
176
|
+
? `${rest.description}\n${note}`
|
|
177
|
+
: note;
|
|
178
|
+
|
|
179
|
+
// Spread `rest` (already sans the combinator keys) so sibling definitions —
|
|
180
|
+
// `$defs`/`definitions` — survive. Without this, a variant serialized into
|
|
181
|
+
// the description as `{"$ref":"#/definitions/A"}` would point at a definition
|
|
182
|
+
// that no longer exists anywhere in the tool. The mergeable path preserves
|
|
183
|
+
// `restSansObjectKeys` for the same reason; the fallback extends the courtesy.
|
|
184
|
+
return {
|
|
185
|
+
...rest,
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: isPlainObject(rest.properties) ? rest.properties : {},
|
|
188
|
+
additionalProperties: true,
|
|
189
|
+
description:
|
|
190
|
+
baseDescription.length > MAX_FALLBACK_DESCRIPTION
|
|
191
|
+
? baseDescription.slice(0, MAX_FALLBACK_DESCRIPTION)
|
|
192
|
+
: baseDescription,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk';
|
|
6
|
+
import { assertWithinByteBudget, shedImagesToFitByteBudget } from '../utils/image-media.js';
|
|
6
7
|
import type {
|
|
7
8
|
ProviderAdapter,
|
|
8
9
|
ProviderRequest,
|
|
@@ -20,6 +21,52 @@ import {
|
|
|
20
21
|
serverError,
|
|
21
22
|
abortError,
|
|
22
23
|
} from '../types/index.js';
|
|
24
|
+
import { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Model capability gates
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Models that reject the sampling parameters (`temperature`, `top_p`,
|
|
32
|
+
* `top_k`) with a 400 invalid_request_error — which Membrane classifies as
|
|
33
|
+
* non-retryable, so a single stray `temperature` kills the whole turn.
|
|
34
|
+
* Mirrors the `noTemperatureSupport` gate in the OpenAI provider.
|
|
35
|
+
*
|
|
36
|
+
* This is the always-on-thinking / reasoning-forward tier, which removes the
|
|
37
|
+
* sampling parameters from the API surface entirely (Sonnet 5 rejects only
|
|
38
|
+
* non-default values). Everything else — Haiku 4.5, Sonnet 4.6, Opus 4.6 and
|
|
39
|
+
* older — ACCEPTS `temperature`, so it must NOT be listed here.
|
|
40
|
+
*
|
|
41
|
+
* - Opus 4.7 / Opus 4.8 / Sonnet 5 / Fable 5 / Mythos 5 / Mythos preview:
|
|
42
|
+
* documented 400 on any sampling parameter.
|
|
43
|
+
*
|
|
44
|
+
* NB: claude-haiku-4-5 was previously listed here on the strength of a single
|
|
45
|
+
* "observed 400 in production when temperature is sent" anecdote. Haiku 4.5
|
|
46
|
+
* documentably supports `temperature`; the production 400 was almost certainly
|
|
47
|
+
* the `extra`-params bypass fixed in this same PR (a sampling param smuggled
|
|
48
|
+
* through `extra` and re-inserted after the gate — 400s on any model), not a
|
|
49
|
+
* capability of Haiku. Listing it silently discarded a valid parameter on the
|
|
50
|
+
* most common cheap model, so it has been removed.
|
|
51
|
+
*
|
|
52
|
+
* Prefix-matched, so dated snapshots (e.g. claude-opus-4-8-20251001) are
|
|
53
|
+
* covered. Keep this list updated as models launch.
|
|
54
|
+
*/
|
|
55
|
+
const NO_TEMPERATURE_MODELS = [
|
|
56
|
+
'claude-opus-4-7',
|
|
57
|
+
'claude-opus-4-8',
|
|
58
|
+
'claude-sonnet-5',
|
|
59
|
+
'claude-fable-5',
|
|
60
|
+
'claude-mythos-5',
|
|
61
|
+
'claude-mythos-preview',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a model doesn't support custom sampling parameters
|
|
66
|
+
*/
|
|
67
|
+
function noTemperatureSupport(model: string): boolean {
|
|
68
|
+
return NO_TEMPERATURE_MODELS.some(prefix => model.startsWith(prefix));
|
|
69
|
+
}
|
|
23
70
|
|
|
24
71
|
// ============================================================================
|
|
25
72
|
// Adapter Configuration
|
|
@@ -326,6 +373,13 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
326
373
|
};
|
|
327
374
|
});
|
|
328
375
|
|
|
376
|
+
// Byte-wall INVARIANT at the last exit before the SDK (2026-07-12): the
|
|
377
|
+
// policy decision (shed with explicit opt-in, or fail loudly) happens
|
|
378
|
+
// upstream at the request-build sites. Reaching this point oversize means
|
|
379
|
+
// a compile path bypassed the policy — throw with the breakdown rather
|
|
380
|
+
// than silently mutate or eat a 413 round-trip.
|
|
381
|
+
assertWithinByteBudget(sanitizedMessages, undefined, 'anthropic-provider');
|
|
382
|
+
|
|
329
383
|
const params: Anthropic.MessageCreateParams = {
|
|
330
384
|
model: request.model,
|
|
331
385
|
max_tokens: request.maxTokens || this.defaultMaxTokens,
|
|
@@ -342,17 +396,42 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
342
396
|
}
|
|
343
397
|
}
|
|
344
398
|
|
|
345
|
-
|
|
399
|
+
// Sampling-parameter gates:
|
|
400
|
+
// - Some models reject temperature/top_p/top_k outright with a 400
|
|
401
|
+
// (see NO_TEMPERATURE_MODELS) — strip rather than let the whole
|
|
402
|
+
// inference die on a non-retryable invalid_request_error.
|
|
403
|
+
// - Extended thinking rejects custom temperature/top_k on every model
|
|
404
|
+
// (only the defaults are accepted while thinking is on) — strip those
|
|
405
|
+
// too when a thinking config is present and not disabled.
|
|
406
|
+
const stripSampling = noTemperatureSupport(request.model);
|
|
407
|
+
// Thinking can arrive top-level OR smuggled through `extra` — the same
|
|
408
|
+
// `Object.assign(params, rest)` below installs `extra.thinking` into the
|
|
409
|
+
// request AFTER this gate ran. Resolve from both sources so an enabled
|
|
410
|
+
// thinking config strips sampling params no matter where it came from;
|
|
411
|
+
// otherwise `extra: { thinking, temperature }` reproduces the exact 400
|
|
412
|
+
// this gate exists to prevent (same bug class as the extra-sampling bypass,
|
|
413
|
+
// one field over).
|
|
414
|
+
const extraThinking = (request.extra as { thinking?: { type?: string } } | undefined)?.thinking;
|
|
415
|
+
const thinkingConfig = request.thinking ?? extraThinking;
|
|
416
|
+
const thinkingOn = thinkingConfig !== undefined && thinkingConfig.type !== 'disabled';
|
|
417
|
+
|
|
418
|
+
if (request.temperature !== undefined && !stripSampling && !thinkingOn) {
|
|
346
419
|
params.temperature = request.temperature;
|
|
347
420
|
}
|
|
348
421
|
|
|
349
422
|
// Anthropic API rejects requests with both temperature and top_p set.
|
|
350
423
|
// When both are provided, prefer temperature (more commonly tuned) and drop top_p.
|
|
351
|
-
|
|
424
|
+
// With thinking on, top_p is only accepted in [0.95, 1] — strip values below.
|
|
425
|
+
if (
|
|
426
|
+
request.topP !== undefined &&
|
|
427
|
+
params.temperature === undefined &&
|
|
428
|
+
!stripSampling &&
|
|
429
|
+
(!thinkingOn || request.topP >= 0.95)
|
|
430
|
+
) {
|
|
352
431
|
params.top_p = request.topP;
|
|
353
432
|
}
|
|
354
433
|
|
|
355
|
-
if (request.topK !== undefined) {
|
|
434
|
+
if (request.topK !== undefined && !stripSampling && !thinkingOn) {
|
|
356
435
|
params.top_k = request.topK;
|
|
357
436
|
}
|
|
358
437
|
|
|
@@ -361,17 +440,43 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|
|
361
440
|
}
|
|
362
441
|
|
|
363
442
|
if (request.tools && request.tools.length > 0) {
|
|
364
|
-
|
|
443
|
+
// MCP allows a root-level oneOf/anyOf/allOf in a tool's input schema,
|
|
444
|
+
// but the Anthropic API rejects it ("input_schema does not support
|
|
445
|
+
// oneOf, allOf, or anyOf at the top level") — one bad tool 400s the
|
|
446
|
+
// entire inference. Flatten such roots into a single object schema
|
|
447
|
+
// before shipping (see anthropic-tool-schema.ts).
|
|
448
|
+
params.tools = (request.tools as Anthropic.Tool[]).map(tool => {
|
|
449
|
+
const inputSchema = (tool as { input_schema?: unknown }).input_schema;
|
|
450
|
+
const flattened = flattenRootSchemaUnion(inputSchema);
|
|
451
|
+
return flattened === inputSchema
|
|
452
|
+
? tool
|
|
453
|
+
: ({ ...tool, input_schema: flattened } as Anthropic.Tool);
|
|
454
|
+
});
|
|
365
455
|
}
|
|
366
456
|
|
|
367
457
|
// Handle extended thinking
|
|
368
|
-
if (
|
|
369
|
-
(params as any).thinking =
|
|
458
|
+
if (request.thinking) {
|
|
459
|
+
(params as any).thinking = request.thinking;
|
|
370
460
|
}
|
|
371
461
|
|
|
372
462
|
// Apply extra params, excluding internal membrane fields
|
|
373
463
|
if (request.extra) {
|
|
374
464
|
const { normalizedMessages, prompt, ...rest } = request.extra as Record<string, unknown>;
|
|
465
|
+
// Sampling params passed through `extra` must obey the same gates as the
|
|
466
|
+
// top-level ones. Otherwise a caller passing e.g. `extra: { temperature }`
|
|
467
|
+
// for a reject-list model (or under extended thinking) would re-insert the
|
|
468
|
+
// stripped value here — via Object.assign, after the gate above — and
|
|
469
|
+
// reproduce the exact non-retryable 400 this stripping is meant to prevent.
|
|
470
|
+
if (stripSampling || thinkingOn) {
|
|
471
|
+
delete rest.temperature;
|
|
472
|
+
delete rest.top_k;
|
|
473
|
+
// top_p is accepted in [0.95, 1] while thinking is on, but never when
|
|
474
|
+
// the model rejects sampling params outright.
|
|
475
|
+
const extraTopP = rest.top_p;
|
|
476
|
+
if (stripSampling || typeof extraTopP !== 'number' || extraTopP < 0.95) {
|
|
477
|
+
delete rest.top_p;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
375
480
|
Object.assign(params, rest);
|
|
376
481
|
}
|
|
377
482
|
|
package/src/providers/gemini.ts
CHANGED
|
@@ -428,6 +428,20 @@ export class GeminiAdapter implements ProviderAdapter {
|
|
|
428
428
|
},
|
|
429
429
|
});
|
|
430
430
|
}
|
|
431
|
+
} else if (block.type === 'audio') {
|
|
432
|
+
// Audio input → Gemini inlineData (same mechanism as images). This is
|
|
433
|
+
// pure plumbing: whether a given model accepts/understands audio is the
|
|
434
|
+
// model's concern (the caller decides what to send). Common MIME types:
|
|
435
|
+
// audio/mp3, audio/wav, audio/ogg, audio/flac.
|
|
436
|
+
const source = block.source;
|
|
437
|
+
if (source?.type === 'base64' && source.data) {
|
|
438
|
+
parts.push({
|
|
439
|
+
inlineData: {
|
|
440
|
+
mimeType: source.mediaType ?? source.media_type ?? 'audio/mpeg',
|
|
441
|
+
data: source.data,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
431
445
|
} else if (block.type === 'tool_use') {
|
|
432
446
|
parts.push({
|
|
433
447
|
functionCall: {
|