@animalabs/membrane 0.5.68 → 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 +40 -12
- 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 +43 -12
- 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
|
// ============================================================================
|
|
@@ -1045,19 +1050,26 @@ export class Membrane {
|
|
|
1045
1050
|
content.push({ ...(block as unknown as Record<string, unknown>) });
|
|
1046
1051
|
} else if (block.type === 'image') {
|
|
1047
1052
|
if (block.source.type === 'base64') {
|
|
1048
|
-
|
|
1049
|
-
type:
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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);
|
|
1059
1072
|
}
|
|
1060
|
-
content.push(imageBlock);
|
|
1061
1073
|
}
|
|
1062
1074
|
}
|
|
1063
1075
|
}
|
|
@@ -1132,6 +1144,14 @@ export class Membrane {
|
|
|
1132
1144
|
// Anthropic requires temperature=1 when extended thinking is enabled
|
|
1133
1145
|
const temperature = alwaysOnThinking ? undefined : (thinking ? 1 : request.config.temperature);
|
|
1134
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
|
+
|
|
1135
1155
|
return {
|
|
1136
1156
|
model: request.config.model,
|
|
1137
1157
|
maxTokens: request.config.maxTokens,
|
|
@@ -1369,6 +1389,17 @@ export class Membrane {
|
|
|
1369
1389
|
prefillUserMessage: request.prefillUserMessage,
|
|
1370
1390
|
});
|
|
1371
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
|
+
|
|
1372
1403
|
const providerRequest = {
|
|
1373
1404
|
...this.getBaseProviderParams(request.config),
|
|
1374
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: {
|